Compare commits
No commits in common. "02d51a8bfe7e19faa12b1d43f2d8e27a0183bb0c" and "f51b7e23da421ebc87edbc8832f0eab6f747916f" have entirely different histories.
02d51a8bfe
...
f51b7e23da
|
@ -13,4 +13,3 @@ hmn.conf
|
|||
adminmailer/config.go
|
||||
adminmailer/adminmailer
|
||||
local/backups
|
||||
/tmp
|
||||
|
|
81
README.md
81
README.md
|
@ -1,81 +0,0 @@
|
|||
# Handmade Network
|
||||
|
||||
This is the codebase for the Handmade Network website, located online at https://handmade.network. The website is the home for all Handmade projects, the forums, the podcast, and other various initiatives.
|
||||
|
||||
The site is written in Go, and uses the standard library HTTP server with custom utilities for routing and middleware. It uses Postgres for its database, making heavy use of the excellent [pgx](https://github.com/jackc/pgx) library along with some custom wrappers. See the documentation in the `db` package for more details.
|
||||
|
||||
We want the website to be a great example of Handmade software on the web. We encourage you to read through the source, run it yourself, and learn from how we work. If you have questions about the code, or you're interested in contributing directly, reach out to us in #network-meta on the [Handmade Network Discord](https://discord.gg/hmn)!
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You will need the following software installed:
|
||||
|
||||
- Go 1.18 or higher: https://go.dev/
|
||||
|
||||
You can download Go directly from the website, or install it through major package managers. If you already have Go installed, but are unsure of the version, you can check by running `go version`.
|
||||
|
||||
- Postgres: https://www.postgresql.org/
|
||||
|
||||
Any Postgres installation should work fine, although less common distributions may not work as nicely with our scripts out of the box. On Mac, [Postgres.app](https://postgresapp.com/) is recommended.
|
||||
|
||||
## First-time setup
|
||||
|
||||
- **Configure the site.** Copy `src/config/config.go.example` to `src/config/config.go`:
|
||||
|
||||
```
|
||||
# On Windows
|
||||
copy src\config\config.go.example src\config\config.go
|
||||
|
||||
# On Mac and Linux
|
||||
cp src/config/config.go.example src/config/config.go
|
||||
```
|
||||
|
||||
Depending on your installation of Postgres, you may need to modify the hostname and port in the Postgres section of the config.
|
||||
|
||||
- **Set up the database.** Run `go run src/main.go db seed` to initialize the database and fill it with sample data.
|
||||
|
||||
- **Update your hosts file.** The website uses subdomains for official projects, so the site cannot simply be run off `localhost`. Add the following
|
||||
line to your hosts file:
|
||||
|
||||
```
|
||||
127.0.0.1 handmade.local hero.handmade.local 4coder.handmade.local
|
||||
```
|
||||
|
||||
You may need to edit the hosts file again in the future if you add more official projects.
|
||||
|
||||
On Windows, the hosts file is located at `C:\Windows\System32\Drivers\etc\hosts`. On Mac and Linux, the hosts file should be located at `/etc/hosts`. It can be edited using any plain text editor, but you will need superuser permissions.
|
||||
|
||||
## Running the site
|
||||
|
||||
Running the site is easy:
|
||||
|
||||
```
|
||||
go run src/main.go
|
||||
```
|
||||
|
||||
You should now be able to visit http://handmade.local:9001 to see the website!
|
||||
|
||||
There are also several other commands built into the website executable. You can see documentation for each of them by running `go run src/main.go help` or adding the `-h` flag to any command.
|
||||
|
||||
## Running tests
|
||||
|
||||
Also easy:
|
||||
|
||||
```
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Note that we have a special coverage requirement for the `hmnurl` package. We use the tests in this package to ensure that our URL builder functions never go out of sync with the regexes used for routing. As a result, we mandate 100% coverage for all `Build` functions in `hmnurl`.
|
||||
|
||||
## Starter data
|
||||
|
||||
The `db seed` command pre-populates the site with realistic data. It also includes several user accounts that you may log into to test various situations:
|
||||
|
||||
| Username | Password |
|
||||
| -------- | -------- |
|
||||
| `admin` | `password` |
|
||||
| `alice` | `password` |
|
||||
| `bob` | `password` |
|
||||
| `charlie` | `password` |
|
||||
|
||||
The `admin` user is a superuser on the site and will have access to all features, as well as the special admin UI for performing site maintenance and moderation. The other users are all normal users.
|
45
go.mod
45
go.mod
|
@ -1,9 +1,10 @@
|
|||
module git.handmade.network/hmn/hmn
|
||||
|
||||
go 1.18
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/HandmadeNetwork/golorem v0.0.0-20220507185207-414965a3a817
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver v1.5.0 // indirect
|
||||
github.com/Masterminds/sprig v2.22.0+incompatible
|
||||
github.com/alecthomas/chroma v0.9.2
|
||||
github.com/aws/aws-sdk-go-v2 v1.8.1
|
||||
|
@ -15,11 +16,14 @@ require (
|
|||
github.com/go-stack/stack v1.8.0
|
||||
github.com/google/uuid v1.2.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/huandu/xstrings v1.3.2 // indirect
|
||||
github.com/imdario/mergo v0.3.12 // indirect
|
||||
github.com/jackc/pgconn v1.8.0
|
||||
github.com/jackc/pgtype v1.6.2
|
||||
github.com/jackc/pgx/v4 v4.10.1
|
||||
github.com/jpillora/backoff v1.0.0
|
||||
github.com/rs/zerolog v1.26.1
|
||||
github.com/mitchellh/copystructure v1.1.1 // indirect
|
||||
github.com/rs/zerolog v1.21.0
|
||||
github.com/spf13/cobra v1.1.3
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
|
||||
|
@ -27,43 +31,10 @@ require (
|
|||
github.com/wellington/go-libsass v0.9.2
|
||||
github.com/yuin/goldmark v1.4.1
|
||||
github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01
|
||||
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
|
||||
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver v1.5.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.4.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.2.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.2.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.2.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.5.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.3.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.6.2 // indirect
|
||||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.4.0 // indirect
|
||||
github.com/huandu/xstrings v1.3.2 // indirect
|
||||
github.com/imdario/mergo v0.3.12 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.0 // indirect
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgproto3/v2 v2.0.6 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
|
||||
github.com/jackc/puddle v1.1.3 // indirect
|
||||
github.com/mitchellh/copystructure v1.1.1 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d // indirect
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e // indirect
|
||||
golang.org/x/text v0.3.6 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
||||
)
|
||||
|
||||
replace (
|
||||
github.com/frustra/bbcode v0.0.0-20201127003707-6ef347fbe1c8 => github.com/HandmadeNetwork/bbcode v0.0.0-20210623031351-ec0e2e2e39d9
|
||||
github.com/yuin/goldmark v1.4.1 => github.com/HandmadeNetwork/goldmark v1.4.1-0.20210707024600-f7e596e26b5e
|
||||
|
|
33
go.sum
33
go.sum
|
@ -17,8 +17,6 @@ github.com/HandmadeNetwork/bbcode v0.0.0-20210623031351-ec0e2e2e39d9 h1:5WhEr56C
|
|||
github.com/HandmadeNetwork/bbcode v0.0.0-20210623031351-ec0e2e2e39d9/go.mod h1:vMiNHD8absjmnO60Do5KCaJBwdbaiI/AzhMmSipMme4=
|
||||
github.com/HandmadeNetwork/goldmark v1.4.1-0.20210707024600-f7e596e26b5e h1:z0GlF2OMmy852mrcMVpjZIzEHYCbUweS8RaWRCPfL1g=
|
||||
github.com/HandmadeNetwork/goldmark v1.4.1-0.20210707024600-f7e596e26b5e/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/HandmadeNetwork/golorem v0.0.0-20220507185207-414965a3a817 h1:cBqVP/sLiK7DPay7Aac1PRUwu3fCVyL5Wc+xLXzqwkE=
|
||||
github.com/HandmadeNetwork/golorem v0.0.0-20220507185207-414965a3a817/go.mod h1:doKbGBIdiM1nkEfvAeP5hvUmERah9H6StTVfCverqdE=
|
||||
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
|
||||
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||
|
@ -79,7 +77,6 @@ github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc
|
|||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
|
@ -103,7 +100,6 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9
|
|||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
|
@ -163,6 +159,7 @@ github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
|
|||
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
|
||||
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
|
||||
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||
|
@ -181,6 +178,7 @@ github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2 h1:JVX6jT/XfzNqIjye47
|
|||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
||||
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/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
|
||||
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
||||
|
@ -291,11 +289,10 @@ github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40T
|
|||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc=
|
||||
github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc=
|
||||
github.com/rs/zerolog v1.21.0 h1:Q3vdXlfLNT+OftyBHsU0Y445MD+8m8axjKgf2si0QcM=
|
||||
github.com/rs/zerolog v1.21.0/go.mod h1:ZPhntP/xmq1nnND05hhpAh2QMhSsA4UN3MGZ6O2J3hM=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
|
@ -341,8 +338,8 @@ github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1
|
|||
github.com/wellington/go-libsass v0.9.2 h1:6Ims04UDdBs6/CGSVK5JC8FNikR5ssrsMMKE/uaO5Q8=
|
||||
github.com/wellington/go-libsass v0.9.2/go.mod h1:mxgxgam0N0E+NAUMHLcu20Ccfc3mVpDkyrLDayqfiTs=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.6/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01 h1:0SJnXjE4jDClMW6grE0xpNhwpqbPwkBTn8zpVw5C0SI=
|
||||
github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01/go.mod h1:TwKQPa5XkCCRC2GRZ5wtfNUTQ2+9/i19mGRijFeJ4BE=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
|
@ -368,8 +365,8 @@ golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8U
|
|||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e h1:1SzTfNOXwIS2oWiMF+6qu0OUDKb0dauo6MoDUQyu+yU=
|
||||
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
|
@ -390,7 +387,7 @@ golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU
|
|||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
|
@ -406,9 +403,9 @@ golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn
|
|||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d h1:20cMwl2fHAzkJMEA+8J4JgqBQcQGzbisXo31MIeenXI=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
|
@ -417,7 +414,7 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
|
|||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
|
@ -440,10 +437,10 @@ golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e h1:WUoyKPm6nCo1BnNUvPGnFG3T5DUVem42yDJZZ4CNxMA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
@ -475,7 +472,7 @@ golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtn
|
|||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# This script is for use in local development only. It wipes the existing db,
|
||||
# creates a new empty one, runs the initial migration to create the schema,
|
||||
# and then imports actual db content on top of that.
|
||||
|
||||
# TODO(opensource): We should adapt Asaf's seedfile command and then delete this.
|
||||
|
||||
THIS_PATH=$(pwd)
|
||||
BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta'
|
||||
# BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta'
|
||||
|
||||
pushd $BETA_PATH
|
||||
docker-compose down -v
|
||||
docker-compose up -d postgres s3
|
||||
sleep 3
|
||||
|
||||
docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\""
|
||||
popd
|
||||
go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-11-14
|
||||
# go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-10-23
|
|
@ -30,7 +30,7 @@ echo "Running migrations..."
|
|||
systemctl stop hmn
|
||||
do_as hmn <<'SCRIPT'
|
||||
set -euo pipefail
|
||||
/home/hmn/bin/hmn db migrate
|
||||
/home/hmn/bin/hmn migrate
|
||||
SCRIPT
|
||||
systemctl start hmn
|
||||
|
||||
|
|
|
@ -377,8 +377,8 @@ ${BLUE_BOLD}Download and restore a database backup${RESET}
|
|||
|
||||
su hmn
|
||||
cd ~
|
||||
hmn db seedfile <your backup file>
|
||||
hmn db migrate
|
||||
hmn seedfile <your backup file>
|
||||
hmn migrate
|
||||
|
||||
${BLUE_BOLD}Restore static files${RESET}
|
||||
|
||||
|
|
|
@ -37,8 +37,8 @@ func addCreateProjectCommand(projectCommand *cobra.Command) {
|
|||
descParsed := parsing.ParseMarkdown(description, parsing.ForumRealMarkdown)
|
||||
|
||||
ctx := context.Background()
|
||||
conn := db.NewConn()
|
||||
defer conn.Close(ctx)
|
||||
conn := db.NewConnPool(1, 1)
|
||||
defer conn.Close()
|
||||
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
|
@ -55,9 +55,9 @@ func addCreateProjectCommand(projectCommand *cobra.Command) {
|
|||
}
|
||||
hmn := p.Project
|
||||
|
||||
newProjectID, err := db.QueryOneScalar[int](ctx, tx,
|
||||
newProjectID, err := db.QueryInt(ctx, tx,
|
||||
`
|
||||
INSERT INTO project (
|
||||
INSERT INTO handmade_project (
|
||||
slug,
|
||||
name,
|
||||
blurb,
|
||||
|
@ -121,7 +121,7 @@ func addCreateProjectCommand(projectCommand *cobra.Command) {
|
|||
for _, userID := range userIDs {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO user_project (user_id, project_id)
|
||||
INSERT INTO handmade_user_projects (user_id, project_id)
|
||||
VALUES ($1, $2)
|
||||
`,
|
||||
userID,
|
||||
|
@ -160,8 +160,8 @@ func addProjectTagCommand(projectCommand *cobra.Command) {
|
|||
tag, _ := cmd.Flags().GetString("tag")
|
||||
|
||||
ctx := context.Background()
|
||||
conn := db.NewConn()
|
||||
defer conn.Close(ctx)
|
||||
conn := db.NewConnPool(1, 1)
|
||||
defer conn.Close()
|
||||
|
||||
resultTag, err := hmndata.SetProjectTag(ctx, conn, nil, projectID, tag)
|
||||
if err != nil {
|
||||
|
|
|
@ -42,10 +42,10 @@ func init() {
|
|||
password := args[1]
|
||||
|
||||
ctx := context.Background()
|
||||
conn := db.NewConn()
|
||||
defer conn.Close(ctx)
|
||||
conn := db.NewConnPool(1, 1)
|
||||
defer conn.Close()
|
||||
|
||||
row := conn.QueryRow(ctx, "SELECT id, username FROM hmn_user WHERE lower(username) = lower($1)", username)
|
||||
row := conn.QueryRow(ctx, "SELECT id, username FROM auth_user WHERE lower(username) = lower($1)", username)
|
||||
var id int
|
||||
var canonicalUsername string
|
||||
err := row.Scan(&id, &canonicalUsername)
|
||||
|
@ -83,10 +83,10 @@ func init() {
|
|||
username := args[0]
|
||||
|
||||
ctx := context.Background()
|
||||
conn := db.NewConn()
|
||||
defer conn.Close(ctx)
|
||||
conn := db.NewConnPool(1, 1)
|
||||
defer conn.Close()
|
||||
|
||||
res, err := conn.Exec(ctx, "UPDATE hmn_user SET status = $1 WHERE LOWER(username) = LOWER($2);", models.UserStatusConfirmed, username)
|
||||
res, err := conn.Exec(ctx, "UPDATE auth_user SET status = $1 WHERE LOWER(username) = LOWER($2);", models.UserStatusConfirmed, username)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
@ -138,10 +138,10 @@ func init() {
|
|||
}
|
||||
|
||||
ctx := context.Background()
|
||||
conn := db.NewConn()
|
||||
defer conn.Close(ctx)
|
||||
conn := db.NewConnPool(1, 1)
|
||||
defer conn.Close()
|
||||
|
||||
res, err := conn.Exec(ctx, "UPDATE hmn_user SET status = $1 WHERE LOWER(username) = LOWER($2);", status, username)
|
||||
res, err := conn.Exec(ctx, "UPDATE auth_user SET status = $1 WHERE LOWER(username) = LOWER($2);", status, username)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
@ -201,8 +201,8 @@ func init() {
|
|||
projectSlug, _ := cmd.Flags().GetString("project_slug")
|
||||
|
||||
ctx := context.Background()
|
||||
conn := db.NewConn()
|
||||
defer conn.Close(ctx)
|
||||
conn := db.NewConnPool(1, 1)
|
||||
defer conn.Close()
|
||||
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
|
@ -210,7 +210,7 @@ func init() {
|
|||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
projectId, err := db.QueryOneScalar[int](ctx, tx, `SELECT id FROM project WHERE slug = $1`, projectSlug)
|
||||
projectId, err := db.QueryInt(ctx, tx, `SELECT id FROM handmade_project WHERE slug = $1`, projectSlug)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
@ -218,8 +218,8 @@ func init() {
|
|||
var parentId *int
|
||||
if parentSlug == "" {
|
||||
// Select the root subforum
|
||||
id, err := db.QueryOneScalar[int](ctx, tx,
|
||||
`SELECT id FROM subforum WHERE parent_id IS NULL AND project_id = $1`,
|
||||
id, err := db.QueryInt(ctx, tx,
|
||||
`SELECT id FROM handmade_subforum WHERE parent_id IS NULL AND project_id = $1`,
|
||||
projectId,
|
||||
)
|
||||
if err != nil {
|
||||
|
@ -228,8 +228,8 @@ func init() {
|
|||
parentId = &id
|
||||
} else {
|
||||
// Select the parent
|
||||
id, err := db.QueryOneScalar[int](ctx, tx,
|
||||
`SELECT id FROM subforum WHERE slug = $1 AND project_id = $2`,
|
||||
id, err := db.QueryInt(ctx, tx,
|
||||
`SELECT id FROM handmade_subforum WHERE slug = $1 AND project_id = $2`,
|
||||
parentSlug, projectId,
|
||||
)
|
||||
if err != nil {
|
||||
|
@ -238,9 +238,9 @@ func init() {
|
|||
parentId = &id
|
||||
}
|
||||
|
||||
newId, err := db.QueryOneScalar[int](ctx, tx,
|
||||
newId, err := db.QueryInt(ctx, tx,
|
||||
`
|
||||
INSERT INTO subforum (name, slug, blurb, parent_id, project_id)
|
||||
INSERT INTO handmade_subforum (name, slug, blurb, parent_id, project_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`,
|
||||
|
@ -280,8 +280,8 @@ func init() {
|
|||
subforumSlug, _ := cmd.Flags().GetString("subforum_slug")
|
||||
|
||||
ctx := context.Background()
|
||||
conn := db.NewConn()
|
||||
defer conn.Close(ctx)
|
||||
conn := db.NewConnPool(1, 1)
|
||||
defer conn.Close()
|
||||
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
|
@ -289,13 +289,13 @@ func init() {
|
|||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
projectId, err := db.QueryOneScalar[int](ctx, tx, `SELECT id FROM project WHERE slug = $1`, projectSlug)
|
||||
projectId, err := db.QueryInt(ctx, tx, `SELECT id FROM handmade_project WHERE slug = $1`, projectSlug)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
subforumId, err := db.QueryOneScalar[int](ctx, tx,
|
||||
`SELECT id FROM subforum WHERE slug = $1 AND project_id = $2`,
|
||||
subforumId, err := db.QueryInt(ctx, tx,
|
||||
`SELECT id FROM handmade_subforum WHERE slug = $1 AND project_id = $2`,
|
||||
subforumSlug, projectId,
|
||||
)
|
||||
if err != nil {
|
||||
|
@ -314,7 +314,7 @@ func init() {
|
|||
|
||||
threadsTag, err := tx.Exec(ctx,
|
||||
`
|
||||
UPDATE thread
|
||||
UPDATE handmade_thread
|
||||
SET
|
||||
project_id = $2,
|
||||
subforum_id = $3,
|
||||
|
@ -330,7 +330,7 @@ func init() {
|
|||
|
||||
postsTag, err := tx.Exec(ctx,
|
||||
`
|
||||
UPDATE post
|
||||
UPDATE handmade_post
|
||||
SET
|
||||
thread_type = 2
|
||||
WHERE
|
||||
|
|
|
@ -67,7 +67,7 @@ func SanitizeFilename(filename string) string {
|
|||
}
|
||||
|
||||
func AssetKey(id, filename string) string {
|
||||
return fmt.Sprintf("%s/%s", id, filename)
|
||||
return fmt.Sprintf("%s%s/%s", config.Config.DigitalOcean.AssetsPathPrefix, id, filename)
|
||||
}
|
||||
|
||||
type InvalidAssetError error
|
||||
|
@ -122,7 +122,7 @@ func Create(ctx context.Context, dbConn db.ConnOrTx, in CreateInput) (*models.As
|
|||
// TODO(db): Would be convient to use RETURNING here...
|
||||
_, err = dbConn.Exec(ctx,
|
||||
`
|
||||
INSERT INTO asset (id, s3_key, filename, size, mime_type, sha1sum, width, height, uploader_id)
|
||||
INSERT INTO handmade_asset (id, s3_key, filename, size, mime_type, sha1sum, width, height, uploader_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`,
|
||||
id,
|
||||
|
@ -140,10 +140,10 @@ func Create(ctx context.Context, dbConn db.ConnOrTx, in CreateInput) (*models.As
|
|||
}
|
||||
|
||||
// Fetch and return the new record
|
||||
asset, err := db.QueryOne[models.Asset](ctx, dbConn,
|
||||
iasset, err := db.QueryOne(ctx, dbConn, models.Asset{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM asset
|
||||
FROM handmade_asset
|
||||
WHERE id = $1
|
||||
`,
|
||||
id,
|
||||
|
@ -152,5 +152,5 @@ func Create(ctx context.Context, dbConn db.ConnOrTx, in CreateInput) (*models.As
|
|||
return nil, oops.New(err, "failed to fetch newly-created asset")
|
||||
}
|
||||
|
||||
return asset, nil
|
||||
return iasset.(*models.Asset), nil
|
||||
}
|
||||
|
|
|
@ -14,7 +14,6 @@ import (
|
|||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/jobs"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
|
@ -181,7 +180,7 @@ func HashPassword(password string) HashedPassword {
|
|||
var ErrUserDoesNotExist = errors.New("user does not exist")
|
||||
|
||||
func UpdatePassword(ctx context.Context, conn db.ConnOrTx, username string, hp HashedPassword) error {
|
||||
tag, err := conn.Exec(ctx, "UPDATE hmn_user SET password = $1 WHERE username = $2", hp.String(), username)
|
||||
tag, err := conn.Exec(ctx, "UPDATE auth_user SET password = $1 WHERE username = $2", hp.String(), username)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to update password")
|
||||
} else if tag.RowsAffected() < 1 {
|
||||
|
@ -191,18 +190,13 @@ func UpdatePassword(ctx context.Context, conn db.ConnOrTx, username string, hp H
|
|||
return nil
|
||||
}
|
||||
|
||||
func SetPassword(ctx context.Context, conn db.ConnOrTx, username string, password string) error {
|
||||
hp := HashPassword(password)
|
||||
return UpdatePassword(ctx, conn, username, hp)
|
||||
}
|
||||
|
||||
func DeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) (int64, error) {
|
||||
tag, err := conn.Exec(ctx,
|
||||
`
|
||||
DELETE FROM hmn_user
|
||||
DELETE FROM auth_user
|
||||
WHERE
|
||||
status = $1 AND
|
||||
(SELECT COUNT(*) as ct FROM one_time_token AS ott WHERE ott.owner_id = hmn_user.id AND ott.expires < $2 AND ott.token_type = $3) > 0;
|
||||
(SELECT COUNT(*) as ct FROM handmade_onetimetoken AS ott WHERE ott.owner_id = auth_user.id AND ott.expires < $2 AND ott.token_type = $3) > 0;
|
||||
`,
|
||||
models.UserStatusInactive,
|
||||
time.Now(),
|
||||
|
@ -219,7 +213,7 @@ func DeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) (int64, error)
|
|||
func DeleteExpiredPasswordResets(ctx context.Context, conn *pgxpool.Pool) (int64, error) {
|
||||
tag, err := conn.Exec(ctx,
|
||||
`
|
||||
DELETE FROM one_time_token
|
||||
DELETE FROM handmade_onetimetoken
|
||||
WHERE
|
||||
token_type = $1
|
||||
AND expires < $2
|
||||
|
@ -235,10 +229,10 @@ func DeleteExpiredPasswordResets(ctx context.Context, conn *pgxpool.Pool) (int64
|
|||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func PeriodicallyDeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) jobs.Job {
|
||||
job := jobs.New()
|
||||
func PeriodicallyDeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer job.Done()
|
||||
defer close(done)
|
||||
|
||||
t := time.NewTicker(1 * time.Hour)
|
||||
for {
|
||||
|
@ -266,5 +260,5 @@ func PeriodicallyDeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) jo
|
|||
}
|
||||
}
|
||||
}()
|
||||
return job
|
||||
return done
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ import (
|
|||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/jobs"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
|
@ -46,7 +45,7 @@ func makeCSRFToken() string {
|
|||
var ErrNoSession = errors.New("no session found")
|
||||
|
||||
func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Session, error) {
|
||||
sess, err := db.QueryOne[models.Session](ctx, conn, "SELECT $columns FROM session WHERE id = $1", id)
|
||||
row, err := db.QueryOne(ctx, conn, models.Session{}, "SELECT $columns FROM sessions WHERE id = $1", id)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
return nil, ErrNoSession
|
||||
|
@ -54,6 +53,7 @@ func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Ses
|
|||
return nil, oops.New(err, "failed to get session")
|
||||
}
|
||||
}
|
||||
sess := row.(*models.Session)
|
||||
|
||||
return sess, nil
|
||||
}
|
||||
|
@ -67,7 +67,7 @@ func CreateSession(ctx context.Context, conn *pgxpool.Pool, username string) (*m
|
|||
}
|
||||
|
||||
_, err := conn.Exec(ctx,
|
||||
"INSERT INTO session (id, username, expires_at, csrf_token) VALUES ($1, $2, $3, $4)",
|
||||
"INSERT INTO sessions (id, username, expires_at, csrf_token) VALUES ($1, $2, $3, $4)",
|
||||
session.ID, session.Username, session.ExpiresAt, session.CSRFToken,
|
||||
)
|
||||
if err != nil {
|
||||
|
@ -80,7 +80,7 @@ func CreateSession(ctx context.Context, conn *pgxpool.Pool, username string) (*m
|
|||
// Deletes a session by id. If no session with that id exists, no
|
||||
// error is returned.
|
||||
func DeleteSession(ctx context.Context, conn *pgxpool.Pool, id string) error {
|
||||
_, err := conn.Exec(ctx, "DELETE FROM session WHERE id = $1", id)
|
||||
_, err := conn.Exec(ctx, "DELETE FROM sessions WHERE id = $1", id)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to delete session")
|
||||
}
|
||||
|
@ -91,7 +91,7 @@ func DeleteSession(ctx context.Context, conn *pgxpool.Pool, id string) error {
|
|||
func DeleteSessionForUser(ctx context.Context, conn *pgxpool.Pool, username string) error {
|
||||
_, err := conn.Exec(ctx,
|
||||
`
|
||||
DELETE FROM session
|
||||
DELETE FROM sessions
|
||||
WHERE LOWER(username) = LOWER($1)
|
||||
`,
|
||||
username,
|
||||
|
@ -125,7 +125,7 @@ var DeleteSessionCookie = &http.Cookie{
|
|||
}
|
||||
|
||||
func DeleteExpiredSessions(ctx context.Context, conn *pgxpool.Pool) (int64, error) {
|
||||
tag, err := conn.Exec(ctx, "DELETE FROM session WHERE expires_at <= CURRENT_TIMESTAMP")
|
||||
tag, err := conn.Exec(ctx, "DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP")
|
||||
if err != nil {
|
||||
return 0, oops.New(err, "failed to delete expired sessions")
|
||||
}
|
||||
|
@ -133,10 +133,10 @@ func DeleteExpiredSessions(ctx context.Context, conn *pgxpool.Pool) (int64, erro
|
|||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func PeriodicallyDeleteExpiredSessions(ctx context.Context, conn *pgxpool.Pool) jobs.Job {
|
||||
job := jobs.New()
|
||||
func PeriodicallyDeleteExpiredSessions(ctx context.Context, conn *pgxpool.Pool) <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer job.Done()
|
||||
defer close(done)
|
||||
|
||||
t := time.NewTicker(1 * time.Minute)
|
||||
for {
|
||||
|
@ -155,5 +155,5 @@ func PeriodicallyDeleteExpiredSessions(ctx context.Context, conn *pgxpool.Pool)
|
|||
}
|
||||
}
|
||||
}()
|
||||
return job
|
||||
return done
|
||||
}
|
||||
|
|
|
@ -7,14 +7,14 @@ import (
|
|||
|
||||
var Config = HMNConfig{
|
||||
Env: Dev,
|
||||
Addr: "localhost:9001",
|
||||
PrivateAddr: "localhost:9002",
|
||||
Addr: ":9001",
|
||||
PrivateAddr: ":9002",
|
||||
BaseUrl: "http://handmade.local:9001",
|
||||
LogLevel: zerolog.TraceLevel, // InfoLevel is recommended for production
|
||||
Postgres: PostgresConfig{
|
||||
User: "hmn",
|
||||
Password: "password",
|
||||
Hostname: "localhost",
|
||||
Hostname: "handmade.local",
|
||||
Port: 5432,
|
||||
DbName: "hmn",
|
||||
LogLevel: pgx.LogLevelTrace, // LogLevelWarn is recommended for production
|
||||
|
@ -35,24 +35,16 @@ var Config = HMNConfig{
|
|||
FromAddress: "noreply@example.com",
|
||||
FromAddressPassword: "",
|
||||
FromName: "Handmade Network Team",
|
||||
|
||||
ForceToAddress: "localdev@example.com", // NOTE(asaf): If this is not empty, all emails will be sent to this address.
|
||||
OverrideRecipientEmail: "override@handmade.network", // NOTE(asaf): If this is not empty, all emails will be redirected to this address.
|
||||
},
|
||||
DigitalOcean: DigitalOceanConfig{
|
||||
AssetsSpacesKey: "dummy",
|
||||
AssetsSpacesSecret: "dummy",
|
||||
AssetsSpacesRegion: "dummy",
|
||||
AssetsSpacesEndpoint: "http://handmade.local:9003/",
|
||||
AssetsSpacesBucket: "assets",
|
||||
AssetsPublicUrlRoot: "http://handmade.local:9003/assets/",
|
||||
// In prod, AssetsPublicUrlRoot will probably look something like:
|
||||
//
|
||||
// "https://bucket-name.region.cdn.digitaloceanspaces.com/"
|
||||
//
|
||||
// Note the trailing slash...
|
||||
|
||||
RunFakeServer: true,
|
||||
FakeAddr: "localhost:9003",
|
||||
AssetsSpacesKey: "",
|
||||
AssetsSpacesSecret: "",
|
||||
AssetsSpacesRegion: "",
|
||||
AssetsSpacesEndpoint: "",
|
||||
AssetsSpacesBucket: "",
|
||||
AssetsPathPrefix: "", // Empty is fine for production, but may be necessary for dev
|
||||
AssetsPublicUrlRoot: "", // e.g. "https://bucket-name.region.cdn.digitaloceanspaces.com/". Note the trailing slash...
|
||||
},
|
||||
Discord: DiscordConfig{
|
||||
BotToken: "",
|
||||
|
|
|
@ -53,10 +53,8 @@ type DigitalOceanConfig struct {
|
|||
AssetsSpacesRegion string
|
||||
AssetsSpacesEndpoint string
|
||||
AssetsSpacesBucket string
|
||||
AssetsPathPrefix string
|
||||
AssetsPublicUrlRoot string
|
||||
|
||||
RunFakeServer bool
|
||||
FakeAddr string
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
|
@ -65,7 +63,7 @@ type EmailConfig struct {
|
|||
FromAddress string
|
||||
FromAddressPassword string
|
||||
FromName string
|
||||
ForceToAddress string
|
||||
OverrideRecipientEmail string
|
||||
}
|
||||
|
||||
type DiscordConfig struct {
|
||||
|
|
733
src/db/db.go
733
src/db/db.go
|
@ -5,13 +5,11 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgconn"
|
||||
"github.com/jackc/pgtype"
|
||||
|
@ -22,18 +20,46 @@ import (
|
|||
)
|
||||
|
||||
/*
|
||||
A general error to be used when no results are found. This is the error returned
|
||||
by QueryOne, and can generally be used by other database helpers that fetch a single
|
||||
result but find nothing.
|
||||
Values of these kinds are ok to query even if they are not directly understood by pgtype.
|
||||
This is common for custom types like:
|
||||
|
||||
type ThreadType int
|
||||
*/
|
||||
var NotFound = errors.New("not found")
|
||||
var queryableKinds = []reflect.Kind{
|
||||
reflect.Int,
|
||||
}
|
||||
|
||||
/*
|
||||
Checks if we are able to handle a particular type in a database query. This applies only to
|
||||
primitive types and not structs, since the database only returns individual primitive types
|
||||
and it is our job to stitch them back together into structs later.
|
||||
*/
|
||||
func typeIsQueryable(t reflect.Type) bool {
|
||||
_, isRecognizedByPgtype := connInfo.DataTypeForValue(reflect.New(t).Elem().Interface()) // if pgtype recognizes it, we don't need to dig in further for more `db` tags
|
||||
// NOTE: boy it would be nice if we didn't have to do reflect.New here, considering that pgtype is just doing reflection on the value anyway
|
||||
|
||||
if isRecognizedByPgtype {
|
||||
return true
|
||||
} else if t == reflect.TypeOf(uuid.UUID{}) {
|
||||
return true
|
||||
}
|
||||
|
||||
// pgtype doesn't recognize it, but maybe it's a primitive type we can deal with
|
||||
k := t.Kind()
|
||||
for _, qk := range queryableKinds {
|
||||
if k == qk {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// This interface should match both a direct pgx connection or a pgx transaction.
|
||||
type ConnOrTx interface {
|
||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||
CopyFrom(ctx context.Context, tableName pgx.Identifier, columnNames []string, rowSrc pgx.CopyFromSource) (int64, error)
|
||||
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row
|
||||
Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error)
|
||||
|
||||
// Both raw database connections and transactions in pgx can begin/commit
|
||||
// transactions. For database connections it does the obvious thing; for
|
||||
|
@ -44,21 +70,8 @@ type ConnOrTx interface {
|
|||
|
||||
var connInfo = pgtype.NewConnInfo()
|
||||
|
||||
// Creates a new connection to the HMN database.
|
||||
// This connection is not safe for concurrent use.
|
||||
func NewConn() *pgx.Conn {
|
||||
return NewConnWithConfig(config.PostgresConfig{})
|
||||
}
|
||||
|
||||
func NewConnWithConfig(cfg config.PostgresConfig) *pgx.Conn {
|
||||
cfg = overrideDefaultConfig(cfg)
|
||||
|
||||
pgcfg, err := pgx.ParseConfig(cfg.DSN())
|
||||
|
||||
pgcfg.Logger = zerologadapter.NewLogger(log.Logger)
|
||||
pgcfg.LogLevel = cfg.LogLevel
|
||||
|
||||
conn, err := pgx.ConnectConfig(context.Background(), pgcfg)
|
||||
conn, err := pgx.Connect(context.Background(), config.Config.Postgres.DSN())
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to connect to database"))
|
||||
}
|
||||
|
@ -66,23 +79,15 @@ func NewConnWithConfig(cfg config.PostgresConfig) *pgx.Conn {
|
|||
return conn
|
||||
}
|
||||
|
||||
// Creates a connection pool for the HMN database.
|
||||
// The resulting pool is safe for concurrent use.
|
||||
func NewConnPool() *pgxpool.Pool {
|
||||
return NewConnPoolWithConfig(config.PostgresConfig{})
|
||||
}
|
||||
func NewConnPool(minConns, maxConns int32) *pgxpool.Pool {
|
||||
cfg, err := pgxpool.ParseConfig(config.Config.Postgres.DSN())
|
||||
|
||||
func NewConnPoolWithConfig(cfg config.PostgresConfig) *pgxpool.Pool {
|
||||
cfg = overrideDefaultConfig(cfg)
|
||||
cfg.MinConns = minConns
|
||||
cfg.MaxConns = maxConns
|
||||
cfg.ConnConfig.Logger = zerologadapter.NewLogger(log.Logger)
|
||||
cfg.ConnConfig.LogLevel = config.Config.Postgres.LogLevel
|
||||
|
||||
pgcfg, err := pgxpool.ParseConfig(cfg.DSN())
|
||||
|
||||
pgcfg.MinConns = cfg.MinConn
|
||||
pgcfg.MaxConns = cfg.MaxConn
|
||||
pgcfg.ConnConfig.Logger = zerologadapter.NewLogger(log.Logger)
|
||||
pgcfg.ConnConfig.LogLevel = cfg.LogLevel
|
||||
|
||||
conn, err := pgxpool.ConnectConfig(context.Background(), pgcfg)
|
||||
conn, err := pgxpool.ConnectConfig(context.Background(), cfg)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to create database connection pool"))
|
||||
}
|
||||
|
@ -90,33 +95,144 @@ func NewConnPoolWithConfig(cfg config.PostgresConfig) *pgxpool.Pool {
|
|||
return conn
|
||||
}
|
||||
|
||||
func overrideDefaultConfig(cfg config.PostgresConfig) config.PostgresConfig {
|
||||
return config.PostgresConfig{
|
||||
User: utils.OrDefault(cfg.User, config.Config.Postgres.User),
|
||||
Password: utils.OrDefault(cfg.Password, config.Config.Postgres.Password),
|
||||
Hostname: utils.OrDefault(cfg.Hostname, config.Config.Postgres.Hostname),
|
||||
Port: utils.OrDefault(cfg.Port, config.Config.Postgres.Port),
|
||||
DbName: utils.OrDefault(cfg.DbName, config.Config.Postgres.DbName),
|
||||
LogLevel: utils.OrDefault(cfg.LogLevel, config.Config.Postgres.LogLevel),
|
||||
MinConn: utils.OrDefault(cfg.MinConn, config.Config.Postgres.MinConn),
|
||||
MaxConn: utils.OrDefault(cfg.MaxConn, config.Config.Postgres.MaxConn),
|
||||
type StructQueryIterator struct {
|
||||
fieldPaths [][]int
|
||||
rows pgx.Rows
|
||||
destType reflect.Type
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func (it *StructQueryIterator) Next() (interface{}, bool) {
|
||||
hasNext := it.rows.Next()
|
||||
if !hasNext {
|
||||
it.Close()
|
||||
return nil, false
|
||||
}
|
||||
|
||||
result := reflect.New(it.destType)
|
||||
|
||||
vals, err := it.rows.Values()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Better logging of panics in this confusing reflection process
|
||||
var currentField reflect.StructField
|
||||
var currentValue reflect.Value
|
||||
var currentIdx int
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if currentValue.IsValid() {
|
||||
logging.Error().
|
||||
Int("index", currentIdx).
|
||||
Str("field name", currentField.Name).
|
||||
Stringer("field type", currentField.Type).
|
||||
Interface("value", currentValue.Interface()).
|
||||
Stringer("value type", currentValue.Type()).
|
||||
Msg("panic in iterator")
|
||||
}
|
||||
|
||||
if currentField.Name != "" {
|
||||
panic(fmt.Errorf("panic while processing field '%s': %v", currentField.Name, r))
|
||||
} else {
|
||||
panic(r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for i, val := range vals {
|
||||
currentIdx = i
|
||||
if val == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var field reflect.Value
|
||||
field, currentField = followPathThroughStructs(result, it.fieldPaths[i])
|
||||
if field.Kind() == reflect.Ptr {
|
||||
field.Set(reflect.New(field.Type().Elem()))
|
||||
field = field.Elem()
|
||||
}
|
||||
|
||||
// Some actual values still come through as pointers (like net.IPNet). Dunno why.
|
||||
// Regardless, we know it's not nil, so we can get at the contents.
|
||||
valReflected := reflect.ValueOf(val)
|
||||
if valReflected.Kind() == reflect.Ptr {
|
||||
valReflected = valReflected.Elem()
|
||||
}
|
||||
currentValue = valReflected
|
||||
|
||||
switch field.Kind() {
|
||||
case reflect.Int:
|
||||
field.SetInt(valReflected.Int())
|
||||
default:
|
||||
field.Set(valReflected)
|
||||
}
|
||||
|
||||
currentField = reflect.StructField{}
|
||||
currentValue = reflect.Value{}
|
||||
}
|
||||
|
||||
return result.Interface(), true
|
||||
}
|
||||
|
||||
func (it *StructQueryIterator) Close() {
|
||||
it.rows.Close()
|
||||
select {
|
||||
case it.closed <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Performs a SQL query and returns a slice of all the result rows. The query is just plain SQL, but make sure to read the package documentation for details. You must explicitly provide the type argument - this is how it knows what Go type to map the results to, and it cannot be inferred.
|
||||
func (it *StructQueryIterator) ToSlice() []interface{} {
|
||||
defer it.Close()
|
||||
var result []interface{}
|
||||
for {
|
||||
row, ok := it.Next()
|
||||
if !ok {
|
||||
err := it.rows.Err()
|
||||
if err != nil {
|
||||
panic(oops.New(err, "error while iterating through db results"))
|
||||
}
|
||||
break
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
Any SQL query may be performed, including INSERT and UPDATE - as long as it returns a result set, you can use this. If the query does not return a result set, or you simply do not care about the result set, call Exec directly on your pgx connection.
|
||||
func followPathThroughStructs(structPtrVal reflect.Value, path []int) (reflect.Value, reflect.StructField) {
|
||||
if len(path) < 1 {
|
||||
panic(oops.New(nil, "can't follow an empty path"))
|
||||
}
|
||||
|
||||
This function always returns pointers to the values. This is convenient for structs, but for other types, you may wish to use QueryScalar.
|
||||
*/
|
||||
func Query[T any](
|
||||
ctx context.Context,
|
||||
conn ConnOrTx,
|
||||
query string,
|
||||
args ...any,
|
||||
) ([]*T, error) {
|
||||
it, err := QueryIterator[T](ctx, conn, query, args...)
|
||||
if structPtrVal.Kind() != reflect.Ptr || structPtrVal.Elem().Kind() != reflect.Struct {
|
||||
panic(oops.New(nil, "structPtrVal must be a pointer to a struct; got value of type %s", structPtrVal.Type()))
|
||||
}
|
||||
|
||||
// more informative panic recovery
|
||||
var field reflect.StructField
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
panic(oops.New(nil, "panic at field '%s': %v", field.Name, r))
|
||||
}
|
||||
}()
|
||||
|
||||
val := structPtrVal
|
||||
for _, i := range path {
|
||||
if val.Kind() == reflect.Ptr && val.Type().Elem().Kind() == reflect.Struct {
|
||||
if val.IsNil() {
|
||||
val.Set(reflect.New(val.Type().Elem()))
|
||||
}
|
||||
val = val.Elem()
|
||||
}
|
||||
field = val.Type().Field(i)
|
||||
val = val.Field(i)
|
||||
}
|
||||
return val, field
|
||||
}
|
||||
|
||||
func Query(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) ([]interface{}, error) {
|
||||
it, err := QueryIterator(ctx, conn, destExample, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
|
@ -124,163 +240,27 @@ func Query[T any](
|
|||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Identical to Query, but panics if there was an error.
|
||||
*/
|
||||
func MustQuery[T any](
|
||||
ctx context.Context,
|
||||
conn ConnOrTx,
|
||||
query string,
|
||||
args ...any,
|
||||
) []*T {
|
||||
result, err := Query[T](ctx, conn, query, args...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/*
|
||||
Identical to Query, but returns only the first result row. If there are no
|
||||
rows in the result set, returns NotFound.
|
||||
*/
|
||||
func QueryOne[T any](
|
||||
ctx context.Context,
|
||||
conn ConnOrTx,
|
||||
query string,
|
||||
args ...any,
|
||||
) (*T, error) {
|
||||
rows, err := QueryIterator[T](ctx, conn, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result, hasRow := rows.Next()
|
||||
if !hasRow {
|
||||
return nil, NotFound
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
/*
|
||||
Identical to QueryOne, but panics if there was an error.
|
||||
*/
|
||||
func MustQueryOne[T any](
|
||||
ctx context.Context,
|
||||
conn ConnOrTx,
|
||||
query string,
|
||||
args ...any,
|
||||
) *T {
|
||||
result, err := QueryOne[T](ctx, conn, query, args...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/*
|
||||
Identical to Query, but returns concrete values instead of pointers. More convenient
|
||||
for primitive types.
|
||||
*/
|
||||
func QueryScalar[T any](
|
||||
ctx context.Context,
|
||||
conn ConnOrTx,
|
||||
query string,
|
||||
args ...any,
|
||||
) ([]T, error) {
|
||||
rows, err := QueryIterator[T](ctx, conn, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []T
|
||||
for {
|
||||
val, hasRow := rows.Next()
|
||||
if !hasRow {
|
||||
break
|
||||
}
|
||||
result = append(result, *val)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
/*
|
||||
Identical to QueryScalar, but panics if there was an error.
|
||||
*/
|
||||
func MustQueryScalar[T any](
|
||||
ctx context.Context,
|
||||
conn ConnOrTx,
|
||||
query string,
|
||||
args ...any,
|
||||
) []T {
|
||||
result, err := QueryScalar[T](ctx, conn, query, args...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/*
|
||||
Identical to QueryScalar, but returns only the first result value. If there are
|
||||
no rows in the result set, returns NotFound.
|
||||
*/
|
||||
func QueryOneScalar[T any](
|
||||
ctx context.Context,
|
||||
conn ConnOrTx,
|
||||
query string,
|
||||
args ...any,
|
||||
) (T, error) {
|
||||
rows, err := QueryIterator[T](ctx, conn, query, args...)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result, hasRow := rows.Next()
|
||||
if !hasRow {
|
||||
var zero T
|
||||
return zero, NotFound
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
|
||||
/*
|
||||
Identical to QueryOneScalar, but panics if there was an error.
|
||||
*/
|
||||
func MustQueryOneScalar[T any](
|
||||
ctx context.Context,
|
||||
conn ConnOrTx,
|
||||
query string,
|
||||
args ...any,
|
||||
) T {
|
||||
result, err := QueryOneScalar[T](ctx, conn, query, args...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/*
|
||||
Identical to Query, but returns the ResultIterator instead of automatically converting the results to a slice. The iterator must be closed after use.
|
||||
*/
|
||||
func QueryIterator[T any](
|
||||
ctx context.Context,
|
||||
conn ConnOrTx,
|
||||
query string,
|
||||
args ...any,
|
||||
) (*Iterator[T], error) {
|
||||
var destExample T
|
||||
func QueryIterator(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) {
|
||||
destType := reflect.TypeOf(destExample)
|
||||
columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, nil)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to generate column names")
|
||||
}
|
||||
|
||||
compiled := compileQuery(query, destType)
|
||||
columns := make([]string, 0, len(columnNames))
|
||||
for _, strSlice := range columnNames {
|
||||
tableName := strings.Join(strSlice[0:len(strSlice)-1], "_")
|
||||
fullName := strSlice[len(strSlice)-1]
|
||||
if tableName != "" {
|
||||
fullName = tableName + "." + fullName
|
||||
}
|
||||
columns = append(columns, fullName)
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, compiled.query, args...)
|
||||
columnNamesString := strings.Join(columns, ", ")
|
||||
query = strings.Replace(query, "$columns", columnNamesString, -1)
|
||||
|
||||
rows, err := conn.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
panic("query exceeded its deadline")
|
||||
|
@ -288,11 +268,10 @@ func QueryIterator[T any](
|
|||
return nil, err
|
||||
}
|
||||
|
||||
it := &Iterator[T]{
|
||||
fieldPaths: compiled.fieldPaths,
|
||||
it := &StructQueryIterator{
|
||||
fieldPaths: fieldPaths,
|
||||
rows: rows,
|
||||
destType: compiled.destType,
|
||||
destTypeIsScalar: typeIsQueryable(compiled.destType),
|
||||
destType: destType,
|
||||
closed: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
|
@ -313,88 +292,16 @@ func QueryIterator[T any](
|
|||
return it, nil
|
||||
}
|
||||
|
||||
/*
|
||||
Identical to QueryIterator, but panics if there was an error.
|
||||
*/
|
||||
func MustQueryIterator[T any](
|
||||
ctx context.Context,
|
||||
conn ConnOrTx,
|
||||
query string,
|
||||
args ...any,
|
||||
) *Iterator[T] {
|
||||
result, err := QueryIterator[T](ctx, conn, query, args...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// TODO: QueryFunc?
|
||||
|
||||
type compiledQuery struct {
|
||||
query string
|
||||
destType reflect.Type
|
||||
fieldPaths []fieldPath
|
||||
}
|
||||
|
||||
var reColumnsPlaceholder = regexp.MustCompile(`\$columns({(.*?)})?`)
|
||||
|
||||
func compileQuery(query string, destType reflect.Type) compiledQuery {
|
||||
columnsMatch := reColumnsPlaceholder.FindStringSubmatch(query)
|
||||
hasColumnsPlaceholder := columnsMatch != nil
|
||||
|
||||
if hasColumnsPlaceholder {
|
||||
// The presence of the $columns placeholder means that the destination type
|
||||
// must be a struct, and we will plonk that struct's fields into the query.
|
||||
|
||||
if destType.Kind() != reflect.Struct {
|
||||
panic("$columns can only be used when querying into a struct")
|
||||
}
|
||||
|
||||
var prefix []string
|
||||
prefixText := columnsMatch[2]
|
||||
if prefixText != "" {
|
||||
prefix = []string{prefixText}
|
||||
}
|
||||
|
||||
columnNames, fieldPaths := getColumnNamesAndPaths(destType, nil, prefix)
|
||||
|
||||
columns := make([]string, 0, len(columnNames))
|
||||
for _, strSlice := range columnNames {
|
||||
tableName := strings.Join(strSlice[0:len(strSlice)-1], "_")
|
||||
fullName := strSlice[len(strSlice)-1]
|
||||
if tableName != "" {
|
||||
fullName = tableName + "." + fullName
|
||||
}
|
||||
columns = append(columns, fullName)
|
||||
}
|
||||
|
||||
columnNamesString := strings.Join(columns, ", ")
|
||||
query = reColumnsPlaceholder.ReplaceAllString(query, columnNamesString)
|
||||
|
||||
return compiledQuery{
|
||||
query: query,
|
||||
destType: destType,
|
||||
fieldPaths: fieldPaths,
|
||||
}
|
||||
} else {
|
||||
return compiledQuery{
|
||||
query: query,
|
||||
destType: destType,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix []string) (names []columnName, paths []fieldPath) {
|
||||
var columnNames []columnName
|
||||
var fieldPaths []fieldPath
|
||||
func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix []string) (names [][]string, paths [][]int, err error) {
|
||||
var columnNames [][]string
|
||||
var fieldPaths [][]int
|
||||
|
||||
if destType.Kind() == reflect.Ptr {
|
||||
destType = destType.Elem()
|
||||
}
|
||||
|
||||
if destType.Kind() != reflect.Struct {
|
||||
panic(fmt.Errorf("can only get column names and paths from a struct, got type '%v' (at prefix '%v')", destType.Name(), prefix))
|
||||
return nil, nil, oops.New(nil, "can only get column names and paths from a struct, got type '%v' (at prefix '%v')", destType.Name(), prefix)
|
||||
}
|
||||
|
||||
type AnonPrefix struct {
|
||||
|
@ -441,214 +348,108 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix []str
|
|||
columnNames = append(columnNames, fieldColumnNames)
|
||||
fieldPaths = append(fieldPaths, path)
|
||||
} else if fieldType.Kind() == reflect.Struct {
|
||||
subCols, subPaths := getColumnNamesAndPaths(fieldType, path, fieldColumnNames)
|
||||
subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, fieldColumnNames)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
columnNames = append(columnNames, subCols...)
|
||||
fieldPaths = append(fieldPaths, subPaths...)
|
||||
} else {
|
||||
panic(fmt.Errorf("field '%s' in type %s has invalid type '%s'", field.Name, destType, field.Type))
|
||||
return nil, nil, oops.New(nil, "field '%s' in type %s has invalid type '%s'", field.Name, destType, field.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return columnNames, fieldPaths
|
||||
return columnNames, fieldPaths, nil
|
||||
}
|
||||
|
||||
/*
|
||||
Values of these kinds are ok to query even if they are not directly understood by pgtype.
|
||||
This is common for custom types like:
|
||||
|
||||
type ThreadType int
|
||||
A general error to be used when no results are found. This is the error returned
|
||||
by QueryOne, and can generally be used by other database helpers that fetch a single
|
||||
result but find nothing.
|
||||
*/
|
||||
var queryableKinds = []reflect.Kind{
|
||||
reflect.Int,
|
||||
var NotFound = errors.New("not found")
|
||||
|
||||
func QueryOne(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (interface{}, error) {
|
||||
rows, err := QueryIterator(ctx, conn, destExample, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result, hasRow := rows.Next()
|
||||
if !hasRow {
|
||||
return nil, NotFound
|
||||
}
|
||||
|
||||
/*
|
||||
Checks if we are able to handle a particular type in a database query. This applies only to
|
||||
primitive types and not structs, since the database only returns individual primitive types
|
||||
and it is our job to stitch them back together into structs later.
|
||||
*/
|
||||
func typeIsQueryable(t reflect.Type) bool {
|
||||
_, isRecognizedByPgtype := connInfo.DataTypeForValue(reflect.New(t).Elem().Interface()) // if pgtype recognizes it, we don't need to dig in further for more `db` tags
|
||||
// NOTE: boy it would be nice if we didn't have to do reflect.New here, considering that pgtype is just doing reflection on the value anyway
|
||||
|
||||
if isRecognizedByPgtype {
|
||||
return true
|
||||
} else if t == reflect.TypeOf(uuid.UUID{}) {
|
||||
return true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// pgtype doesn't recognize it, but maybe it's a primitive type we can deal with
|
||||
k := t.Kind()
|
||||
for _, qk := range queryableKinds {
|
||||
if k == qk {
|
||||
return true
|
||||
}
|
||||
func QueryScalar(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (interface{}, error) {
|
||||
rows, err := conn.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type columnName []string
|
||||
|
||||
// A path to a particular field in query's destination type. Each index in the slice
|
||||
// corresponds to a field index for use with Field on a reflect.Type or reflect.Value.
|
||||
type fieldPath []int
|
||||
|
||||
type Iterator[T any] struct {
|
||||
fieldPaths []fieldPath
|
||||
rows pgx.Rows
|
||||
destType reflect.Type
|
||||
destTypeIsScalar bool // NOTE(ben): Make sure this gets set every time destType gets set, based on typeIsQueryable(destType). This is kinda fragile...but also contained to this file, so doesn't seem worth a lazy evaluation or a constructor function.
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func (it *Iterator[T]) Next() (*T, bool) {
|
||||
// TODO(ben): What happens if this panics? Does it leak resources? Do we need
|
||||
// to put a recover() here and close the rows?
|
||||
|
||||
hasNext := it.rows.Next()
|
||||
if !hasNext {
|
||||
it.Close()
|
||||
return nil, false
|
||||
}
|
||||
|
||||
result := reflect.New(it.destType)
|
||||
|
||||
vals, err := it.rows.Values()
|
||||
if rows.Next() {
|
||||
vals, err := rows.Values()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if it.destTypeIsScalar {
|
||||
// This type can be directly queried, meaning pgx recognizes it, it's
|
||||
// a simple scalar thing, and we can just take the easy way out.
|
||||
if len(vals) != 1 {
|
||||
panic(fmt.Errorf("tried to query a scalar value, but got %v values in the row", len(vals)))
|
||||
}
|
||||
setValueFromDB(result.Elem(), reflect.ValueOf(vals[0]))
|
||||
return result.Interface().(*T), true
|
||||
} else {
|
||||
var currentField reflect.StructField
|
||||
var currentValue reflect.Value
|
||||
var currentIdx int
|
||||
|
||||
// Better logging of panics in this confusing reflection process
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if currentValue.IsValid() {
|
||||
logging.Error().
|
||||
Int("index", currentIdx).
|
||||
Str("field name", currentField.Name).
|
||||
Stringer("field type", currentField.Type).
|
||||
Interface("value", currentValue.Interface()).
|
||||
Stringer("value type", currentValue.Type()).
|
||||
Msg("panic in iterator")
|
||||
return nil, oops.New(nil, "you must query exactly one field with QueryScalar, not %v", len(vals))
|
||||
}
|
||||
|
||||
if currentField.Name != "" {
|
||||
panic(fmt.Errorf("panic while processing field '%s': %v", currentField.Name, r))
|
||||
} else {
|
||||
panic(r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for i, val := range vals {
|
||||
currentIdx = i
|
||||
if val == nil {
|
||||
continue
|
||||
return vals[0], nil
|
||||
}
|
||||
|
||||
var field reflect.Value
|
||||
field, currentField = followPathThroughStructs(result, it.fieldPaths[i])
|
||||
if field.Kind() == reflect.Ptr {
|
||||
field.Set(reflect.New(field.Type().Elem()))
|
||||
field = field.Elem()
|
||||
return nil, NotFound
|
||||
}
|
||||
|
||||
// Some actual values still come through as pointers (like net.IPNet). Dunno why.
|
||||
// Regardless, we know it's not nil, so we can get at the contents.
|
||||
valReflected := reflect.ValueOf(val)
|
||||
if valReflected.Kind() == reflect.Ptr {
|
||||
valReflected = valReflected.Elem()
|
||||
}
|
||||
currentValue = valReflected
|
||||
|
||||
setValueFromDB(field, valReflected)
|
||||
|
||||
currentField = reflect.StructField{}
|
||||
currentValue = reflect.Value{}
|
||||
}
|
||||
|
||||
return result.Interface().(*T), true
|
||||
}
|
||||
}
|
||||
|
||||
func setValueFromDB(dest reflect.Value, value reflect.Value) {
|
||||
switch dest.Kind() {
|
||||
case reflect.Int:
|
||||
dest.SetInt(value.Int())
|
||||
default:
|
||||
dest.Set(value)
|
||||
}
|
||||
}
|
||||
|
||||
func (it *Iterator[any]) Close() {
|
||||
it.rows.Close()
|
||||
select {
|
||||
case it.closed <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Pulls all the remaining values into a slice, and closes the iterator.
|
||||
*/
|
||||
func (it *Iterator[T]) ToSlice() []*T {
|
||||
defer it.Close()
|
||||
var result []*T
|
||||
for {
|
||||
row, ok := it.Next()
|
||||
if !ok {
|
||||
err := it.rows.Err()
|
||||
func QueryString(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (string, error) {
|
||||
result, err := QueryScalar(ctx, conn, query, args...)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "error while iterating through db results"))
|
||||
}
|
||||
break
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
return result
|
||||
return "", err
|
||||
}
|
||||
|
||||
func followPathThroughStructs(structPtrVal reflect.Value, path []int) (reflect.Value, reflect.StructField) {
|
||||
if len(path) < 1 {
|
||||
panic(oops.New(nil, "can't follow an empty path"))
|
||||
switch r := result.(type) {
|
||||
case string:
|
||||
return r, nil
|
||||
default:
|
||||
return "", oops.New(nil, "QueryString got a non-string result: %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
if structPtrVal.Kind() != reflect.Ptr || structPtrVal.Elem().Kind() != reflect.Struct {
|
||||
panic(oops.New(nil, "structPtrVal must be a pointer to a struct; got value of type %s", structPtrVal.Type()))
|
||||
func QueryInt(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (int, error) {
|
||||
result, err := QueryScalar(ctx, conn, query, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// more informative panic recovery
|
||||
var field reflect.StructField
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
panic(oops.New(nil, "panic at field '%s': %v", field.Name, r))
|
||||
switch r := result.(type) {
|
||||
case int:
|
||||
return r, nil
|
||||
case int32:
|
||||
return int(r), nil
|
||||
case int64:
|
||||
return int(r), nil
|
||||
default:
|
||||
return 0, oops.New(nil, "QueryInt got a non-int result: %v", result)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
val := structPtrVal
|
||||
for _, i := range path {
|
||||
if val.Kind() == reflect.Ptr && val.Type().Elem().Kind() == reflect.Struct {
|
||||
if val.IsNil() {
|
||||
val.Set(reflect.New(val.Type().Elem()))
|
||||
func QueryBool(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (bool, error) {
|
||||
result, err := QueryScalar(ctx, conn, query, args...)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
val = val.Elem()
|
||||
|
||||
switch r := result.(type) {
|
||||
case bool:
|
||||
return r, nil
|
||||
default:
|
||||
return false, oops.New(nil, "QueryBool got a non-bool result: %v", result)
|
||||
}
|
||||
field = val.Type().Field(i)
|
||||
val = val.Field(i)
|
||||
}
|
||||
return val, field
|
||||
}
|
||||
|
|
|
@ -10,90 +10,13 @@ import (
|
|||
|
||||
func TestPaths(t *testing.T) {
|
||||
type CustomInt int
|
||||
type S2 struct {
|
||||
B bool `db:"B"` // field 0
|
||||
PB *bool `db:"PB"` // field 1
|
||||
|
||||
NoTag string // field 2
|
||||
}
|
||||
type S struct {
|
||||
I int `db:"I"` // field 0
|
||||
PI *int `db:"PI"` // field 1
|
||||
CI CustomInt `db:"CI"` // field 2
|
||||
PCI *CustomInt `db:"PCI"` // field 3
|
||||
S2 `db:"S2"` // field 4 (embedded!)
|
||||
PS2 *S2 `db:"PS2"` // field 5
|
||||
|
||||
NoTag int // field 6
|
||||
}
|
||||
type Nested struct {
|
||||
S S `db:"S"` // field 0
|
||||
PS *S `db:"PS"` // field 1
|
||||
|
||||
NoTag S // field 2
|
||||
}
|
||||
type Embedded struct {
|
||||
NoTag S // field 0
|
||||
Nested // field 1
|
||||
}
|
||||
|
||||
names, paths := getColumnNamesAndPaths(reflect.TypeOf(Embedded{}), nil, nil)
|
||||
assert.Equal(t, []columnName{
|
||||
{"S", "I"}, {"S", "PI"},
|
||||
{"S", "CI"}, {"S", "PCI"},
|
||||
{"S", "S2", "B"}, {"S", "S2", "PB"},
|
||||
{"S", "PS2", "B"}, {"S", "PS2", "PB"},
|
||||
{"PS", "I"}, {"PS", "PI"},
|
||||
{"PS", "CI"}, {"PS", "PCI"},
|
||||
{"PS", "S2", "B"}, {"PS", "S2", "PB"},
|
||||
{"PS", "PS2", "B"}, {"PS", "PS2", "PB"},
|
||||
}, names)
|
||||
assert.Equal(t, []fieldPath{
|
||||
{1, 0, 0}, {1, 0, 1}, // Nested.S.I, Nested.S.PI
|
||||
{1, 0, 2}, {1, 0, 3}, // Nested.S.CI, Nested.S.PCI
|
||||
{1, 0, 4, 0}, {1, 0, 4, 1}, // Nested.S.S2.B, Nested.S.S2.PB
|
||||
{1, 0, 5, 0}, {1, 0, 5, 1}, // Nested.S.PS2.B, Nested.S.PS2.PB
|
||||
{1, 1, 0}, {1, 1, 1}, // Nested.PS.I, Nested.PS.PI
|
||||
{1, 1, 2}, {1, 1, 3}, // Nested.PS.CI, Nested.PS.PCI
|
||||
{1, 1, 4, 0}, {1, 1, 4, 1}, // Nested.PS.S2.B, Nested.PS.S2.PB
|
||||
{1, 1, 5, 0}, {1, 1, 5, 1}, // Nested.PS.PS2.B, Nested.PS.PS2.PB
|
||||
}, paths)
|
||||
assert.True(t, len(names) == len(paths))
|
||||
|
||||
testStruct := Embedded{}
|
||||
for i, path := range paths {
|
||||
val, field := followPathThroughStructs(reflect.ValueOf(&testStruct), path)
|
||||
assert.True(t, val.IsValid())
|
||||
assert.True(t, strings.Contains(names[i][len(names[i])-1], field.Name))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileQuery(t *testing.T) {
|
||||
t.Run("simple struct", func(t *testing.T) {
|
||||
type Dest struct {
|
||||
Foo int `db:"foo"`
|
||||
Bar bool `db:"bar"`
|
||||
Nope string // no tag
|
||||
}
|
||||
|
||||
compiled := compileQuery("SELECT $columns FROM greeblies", reflect.TypeOf(Dest{}))
|
||||
assert.Equal(t, "SELECT foo, bar FROM greeblies", compiled.query)
|
||||
})
|
||||
t.Run("complex structs", func(t *testing.T) {
|
||||
type CustomInt int
|
||||
type S2 struct {
|
||||
B bool `db:"B"`
|
||||
PB *bool `db:"PB"`
|
||||
|
||||
NoTag string
|
||||
}
|
||||
type S struct {
|
||||
I int `db:"I"`
|
||||
PI *int `db:"PI"`
|
||||
CI CustomInt `db:"CI"`
|
||||
PCI *CustomInt `db:"PCI"`
|
||||
S2 `db:"S2"` // embedded!
|
||||
PS2 *S2 `db:"PS2"`
|
||||
B bool `db:"B"`
|
||||
PB *bool `db:"PB"`
|
||||
|
||||
NoTag int
|
||||
}
|
||||
|
@ -103,48 +26,34 @@ func TestCompileQuery(t *testing.T) {
|
|||
|
||||
NoTag S
|
||||
}
|
||||
type Dest struct {
|
||||
type Embedded struct {
|
||||
NoTag S
|
||||
Nested
|
||||
}
|
||||
|
||||
compiled := compileQuery("SELECT $columns FROM greeblies", reflect.TypeOf(Dest{}))
|
||||
assert.Equal(t, "SELECT S.I, S.PI, S.CI, S.PCI, S_S2.B, S_S2.PB, S_PS2.B, S_PS2.PB, PS.I, PS.PI, PS.CI, PS.PCI, PS_S2.B, PS_S2.PB, PS_PS2.B, PS_PS2.PB FROM greeblies", compiled.query)
|
||||
})
|
||||
t.Run("int", func(t *testing.T) {
|
||||
type Dest int
|
||||
|
||||
// There should be no error here because we do not need to extract columns from
|
||||
// the destination type. There may be errors down the line in value iteration, but
|
||||
// that is always the case if the Go types don't match the query.
|
||||
compiled := compileQuery("SELECT id FROM greeblies", reflect.TypeOf(Dest(0)))
|
||||
assert.Equal(t, "SELECT id FROM greeblies", compiled.query)
|
||||
})
|
||||
t.Run("just one table", func(t *testing.T) {
|
||||
type Dest struct {
|
||||
Foo int `db:"foo"`
|
||||
Bar bool `db:"bar"`
|
||||
Nope string // no tag
|
||||
names, paths, err := getColumnNamesAndPaths(reflect.TypeOf(Embedded{}), nil, "")
|
||||
if assert.Nil(t, err) {
|
||||
assert.Equal(t, []string{
|
||||
"S.I", "S.PI",
|
||||
"S.CI", "S.PCI",
|
||||
"S.B", "S.PB",
|
||||
"PS.I", "PS.PI",
|
||||
"PS.CI", "PS.PCI",
|
||||
"PS.B", "PS.PB",
|
||||
}, names)
|
||||
assert.Equal(t, [][]int{
|
||||
{1, 0, 0}, {1, 0, 1}, {1, 0, 2}, {1, 0, 3}, {1, 0, 4}, {1, 0, 5},
|
||||
{1, 1, 0}, {1, 1, 1}, {1, 1, 2}, {1, 1, 3}, {1, 1, 4}, {1, 1, 5},
|
||||
}, paths)
|
||||
assert.True(t, len(names) == len(paths))
|
||||
}
|
||||
|
||||
// The prefix is necessary because otherwise we would have to provide a struct with
|
||||
// a db tag in order to provide the query with the `greeblies.` prefix in the
|
||||
// final query. This comes up a lot when we do a JOIN to help with a condition, but
|
||||
// don't actually care about any of the data we joined to.
|
||||
compiled := compileQuery(
|
||||
"SELECT $columns{greeblies} FROM greeblies NATURAL JOIN props",
|
||||
reflect.TypeOf(Dest{}),
|
||||
)
|
||||
assert.Equal(t, "SELECT greeblies.foo, greeblies.bar FROM greeblies NATURAL JOIN props", compiled.query)
|
||||
})
|
||||
|
||||
t.Run("using $columns without a struct is not allowed", func(t *testing.T) {
|
||||
type Dest int
|
||||
|
||||
assert.Panics(t, func() {
|
||||
compileQuery("SELECT $columns FROM greeblies", reflect.TypeOf(Dest(0)))
|
||||
})
|
||||
})
|
||||
testStruct := Embedded{}
|
||||
for i, path := range paths {
|
||||
val, field := followPathThroughStructs(reflect.ValueOf(&testStruct), path)
|
||||
assert.True(t, val.IsValid())
|
||||
assert.True(t, strings.Contains(names[i], field.Name))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBuilder(t *testing.T) {
|
||||
|
|
|
@ -1,59 +0,0 @@
|
|||
/*
|
||||
This package contains lowish-level APIs for making database queries to our Postgres database. It streamlines the process of mapping query results to Go types, while allowing you to write arbitrary SQL queries.
|
||||
|
||||
The primary functions are Query and QueryIterator. See the package and function examples for detailed usage.
|
||||
|
||||
Query syntax
|
||||
|
||||
This package allows a few small extensions to SQL syntax to streamline the interaction between Go and Postgres.
|
||||
|
||||
Arguments can be provided using placeholders like $1, $2, etc. All arguments will be safely escaped and mapped from their Go type to the correct Postgres type. (This is a direct proxy to pgx.)
|
||||
|
||||
projectIDs, err := db.Query[int](ctx, conn,
|
||||
`
|
||||
SELECT id
|
||||
FROM project
|
||||
WHERE
|
||||
slug = ANY($1)
|
||||
AND hidden = $2
|
||||
`,
|
||||
[]string{"4coder", "metadesk"},
|
||||
false,
|
||||
)
|
||||
|
||||
(This also demonstrates a useful tip: if you want to use a slice in your query, use Postgres arrays instead of IN.)
|
||||
|
||||
When querying individual fields, you can simply select the field like so:
|
||||
|
||||
ids, err := db.Query[int](ctx, conn, `SELECT id FROM project`)
|
||||
|
||||
To query multiple columns at once, you may use a struct type with `db:"column_name"` tags, and the special $columns placeholder:
|
||||
|
||||
type Project struct {
|
||||
ID int `db:"id"`
|
||||
Slug string `db:"slug"`
|
||||
DateCreated time.Time `db:"date_created"`
|
||||
}
|
||||
projects, err := db.Query[Project](ctx, conn, `SELECT $columns FROM ...`)
|
||||
// Resulting query:
|
||||
// SELECT id, slug, date_created FROM ...
|
||||
|
||||
Sometimes a table name prefix is required on each column to disambiguate between column names, especially when performing a JOIN. In those situations, you can include the prefix in the $columns placeholder like $columns{prefix}:
|
||||
|
||||
type Project struct {
|
||||
ID int `db:"id"`
|
||||
Slug string `db:"slug"`
|
||||
DateCreated time.Time `db:"date_created"`
|
||||
}
|
||||
orphanedProjects, err := db.Query[Project](ctx, conn, `
|
||||
SELECT $columns{projects}
|
||||
FROM
|
||||
project AS projects
|
||||
LEFT JOIN user_project AS uproj
|
||||
WHERE
|
||||
uproj.user_id IS NULL
|
||||
`)
|
||||
// Resulting query:
|
||||
// SELECT projects.id, projects.slug, projects.date_created FROM ...
|
||||
*/
|
||||
package db
|
|
@ -7,7 +7,7 @@ import (
|
|||
|
||||
type QueryBuilder struct {
|
||||
sql strings.Builder
|
||||
args []any
|
||||
args []interface{}
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -18,7 +18,7 @@ of `$?` will be replaced with the correct argument number.
|
|||
foo ARG1 bar ARG2 baz $?
|
||||
foo ARG1 bar ARG2 baz ARG3
|
||||
*/
|
||||
func (qb *QueryBuilder) Add(sql string, args ...any) {
|
||||
func (qb *QueryBuilder) Add(sql string, args ...interface{}) {
|
||||
numPlaceholders := strings.Count(sql, "$?")
|
||||
if numPlaceholders != len(args) {
|
||||
panic(fmt.Errorf("cannot add chunk to query; expected %d arguments but got %d", numPlaceholders, len(args)))
|
||||
|
@ -37,6 +37,6 @@ func (qb *QueryBuilder) String() string {
|
|||
return qb.sql.String()
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) Args() []any {
|
||||
func (qb *QueryBuilder) Args() []interface{} {
|
||||
return qb.args
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ func init() {
|
|||
Long: "Scrape the entire history of Discord channels, saving message content (but not creating snippets)",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ctx := context.Background()
|
||||
conn := db.NewConnPool()
|
||||
conn := db.NewConnPool(1, 1)
|
||||
defer conn.Close()
|
||||
|
||||
for _, channelID := range args {
|
||||
|
@ -47,8 +47,8 @@ func init() {
|
|||
os.Exit(1)
|
||||
}
|
||||
ctx := context.Background()
|
||||
conn := db.NewConn()
|
||||
defer conn.Close(ctx)
|
||||
conn := db.NewConnPool(1, 1)
|
||||
defer conn.Close()
|
||||
|
||||
chanID := args[0]
|
||||
|
||||
|
|
|
@ -90,18 +90,20 @@ func (bot *botInstance) handleProfileCommand(ctx context.Context, i *Interaction
|
|||
return
|
||||
}
|
||||
|
||||
hmnUser, err := db.QueryOne[models.User](ctx, bot.dbConn,
|
||||
type profileResult struct {
|
||||
HMNUser models.User `db:"auth_user"`
|
||||
}
|
||||
ires, err := db.QueryOne(ctx, bot.dbConn, profileResult{},
|
||||
`
|
||||
SELECT $columns{hmn_user}
|
||||
SELECT $columns
|
||||
FROM
|
||||
discord_user AS duser
|
||||
JOIN hmn_user ON duser.hmn_user_id = hmn_user.id
|
||||
handmade_discorduser AS duser
|
||||
JOIN auth_user ON duser.hmn_user_id = auth_user.id
|
||||
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
|
||||
WHERE
|
||||
duser.userid = $1
|
||||
AND hmn_user.status = $2
|
||||
`,
|
||||
userID,
|
||||
models.UserStatusApproved,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
|
@ -120,15 +122,16 @@ func (bot *botInstance) handleProfileCommand(ctx context.Context, i *Interaction
|
|||
}
|
||||
return
|
||||
}
|
||||
res := ires.(*profileResult)
|
||||
|
||||
projectsAndStuff, err := hmndata.FetchProjects(ctx, bot.dbConn, nil, hmndata.ProjectsQuery{
|
||||
OwnerIDs: []int{hmnUser.ID},
|
||||
OwnerIDs: []int{res.HMNUser.ID},
|
||||
})
|
||||
if err != nil {
|
||||
logging.ExtractLogger(ctx).Error().Err(err).Msg("failed to fetch user projects")
|
||||
}
|
||||
|
||||
url := hmnurl.BuildUserProfile(hmnUser.Username)
|
||||
url := hmnurl.BuildUserProfile(res.HMNUser.Username)
|
||||
msg := fmt.Sprintf("<@%s>'s profile can be viewed at %s.", member.User.ID, url)
|
||||
if len(projectsAndStuff) > 0 {
|
||||
projectNoun := "projects"
|
||||
|
|
|
@ -13,7 +13,6 @@ import (
|
|||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/jobs"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
|
@ -23,20 +22,22 @@ import (
|
|||
"github.com/jpillora/backoff"
|
||||
)
|
||||
|
||||
func RunDiscordBot(ctx context.Context, dbConn *pgxpool.Pool) jobs.Job {
|
||||
func RunDiscordBot(ctx context.Context, dbConn *pgxpool.Pool) <-chan struct{} {
|
||||
log := logging.ExtractLogger(ctx).With().Str("module", "discord").Logger()
|
||||
ctx = logging.AttachLoggerToContext(&log, ctx)
|
||||
|
||||
if config.Config.Discord.BotToken == "" {
|
||||
log.Warn().Msg("No Discord bot token was provided, so the Discord bot cannot run.")
|
||||
return jobs.Noop()
|
||||
done := make(chan struct{}, 1)
|
||||
done <- struct{}{}
|
||||
return done
|
||||
}
|
||||
|
||||
job := jobs.New()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer func() {
|
||||
log.Debug().Msg("shut down Discord bot")
|
||||
job.Done()
|
||||
done <- struct{}{}
|
||||
}()
|
||||
|
||||
boff := backoff.Backoff{
|
||||
|
@ -87,7 +88,7 @@ func RunDiscordBot(ctx context.Context, dbConn *pgxpool.Pool) jobs.Job {
|
|||
}()
|
||||
}
|
||||
}()
|
||||
return job
|
||||
return done
|
||||
}
|
||||
|
||||
var outgoingMessagesReady = make(chan struct{}, 1)
|
||||
|
@ -249,7 +250,7 @@ func (bot *botInstance) connect(ctx context.Context) error {
|
|||
// an old one or starting a new one.
|
||||
|
||||
shouldResume := true
|
||||
session, err := db.QueryOne[models.DiscordSession](ctx, bot.dbConn, `SELECT $columns FROM discord_session`)
|
||||
isession, err := db.QueryOne(ctx, bot.dbConn, models.DiscordSession{}, `SELECT $columns FROM discord_session`)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
// No session yet! Just identify and get on with it
|
||||
|
@ -261,6 +262,8 @@ func (bot *botInstance) connect(ctx context.Context) error {
|
|||
|
||||
if shouldResume {
|
||||
// Reconnect to the previous session
|
||||
session := isession.(*models.DiscordSession)
|
||||
|
||||
err := bot.sendGatewayMessage(ctx, GatewayMessage{
|
||||
Opcode: OpcodeResume,
|
||||
Data: Resume{
|
||||
|
@ -353,7 +356,7 @@ func (bot *botInstance) doSender(ctx context.Context) {
|
|||
}
|
||||
bot.didAckHeartbeat = false
|
||||
|
||||
latestSequenceNumber, err := db.QueryOneScalar[int](ctx, bot.dbConn, `SELECT sequence_number FROM discord_session`)
|
||||
latestSequenceNumber, err := db.QueryInt(ctx, bot.dbConn, `SELECT sequence_number FROM discord_session`)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to fetch latest sequence number from the db")
|
||||
return false
|
||||
|
@ -405,9 +408,9 @@ func (bot *botInstance) doSender(ctx context.Context) {
|
|||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
msgs, err := db.Query[models.DiscordOutgoingMessage](ctx, tx, `
|
||||
msgs, err := db.Query(ctx, tx, models.DiscordOutgoingMessage{}, `
|
||||
SELECT $columns
|
||||
FROM discord_outgoing_message
|
||||
FROM discord_outgoingmessages
|
||||
ORDER BY id ASC
|
||||
`)
|
||||
if err != nil {
|
||||
|
@ -415,7 +418,8 @@ func (bot *botInstance) doSender(ctx context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
for _, msg := range msgs {
|
||||
for _, imsg := range msgs {
|
||||
msg := imsg.(*models.DiscordOutgoingMessage)
|
||||
if time.Now().After(msg.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
|
@ -429,7 +433,7 @@ func (bot *botInstance) doSender(ctx context.Context) {
|
|||
|
||||
https://www.postgresql.org/docs/current/transaction-iso.html
|
||||
*/
|
||||
_, err = tx.Exec(ctx, `DELETE FROM discord_outgoing_message`)
|
||||
_, err = tx.Exec(ctx, `DELETE FROM discord_outgoingmessages`)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to delete outgoing messages")
|
||||
return
|
||||
|
@ -643,7 +647,7 @@ func SendMessages(
|
|||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO discord_outgoing_message (channel_id, payload_json, expires_at)
|
||||
INSERT INTO discord_outgoingmessages (channel_id, payload_json, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
`,
|
||||
msg.ChannelID,
|
||||
|
|
|
@ -7,26 +7,27 @@ import (
|
|||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/jobs"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"github.com/jackc/pgx/v4/pgxpool"
|
||||
)
|
||||
|
||||
func RunHistoryWatcher(ctx context.Context, dbConn *pgxpool.Pool) jobs.Job {
|
||||
func RunHistoryWatcher(ctx context.Context, dbConn *pgxpool.Pool) <-chan struct{} {
|
||||
log := logging.ExtractLogger(ctx).With().Str("discord goroutine", "history watcher").Logger()
|
||||
ctx = logging.AttachLoggerToContext(&log, ctx)
|
||||
|
||||
if config.Config.Discord.BotToken == "" {
|
||||
log.Warn().Msg("No Discord bot token was provided, so the Discord history bot cannot run.")
|
||||
return jobs.Noop()
|
||||
done := make(chan struct{}, 1)
|
||||
done <- struct{}{}
|
||||
return done
|
||||
}
|
||||
|
||||
job := jobs.New()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer func() {
|
||||
log.Debug().Msg("shut down Discord history watcher")
|
||||
job.Done()
|
||||
done <- struct{}{}
|
||||
}()
|
||||
|
||||
newUserTicker := time.NewTicker(5 * time.Second)
|
||||
|
@ -66,19 +67,22 @@ func RunHistoryWatcher(ctx context.Context, dbConn *pgxpool.Pool) jobs.Job {
|
|||
}
|
||||
}()
|
||||
|
||||
return job
|
||||
return done
|
||||
}
|
||||
|
||||
func fetchMissingContent(ctx context.Context, dbConn *pgxpool.Pool) {
|
||||
log := logging.ExtractLogger(ctx)
|
||||
|
||||
messagesWithoutContent, err := db.Query[models.DiscordMessage](ctx, dbConn,
|
||||
type query struct {
|
||||
Message models.DiscordMessage `db:"msg"`
|
||||
}
|
||||
imessagesWithoutContent, err := db.Query(ctx, dbConn, query{},
|
||||
`
|
||||
SELECT $columns{msg}
|
||||
SELECT $columns
|
||||
FROM
|
||||
discord_message AS msg
|
||||
JOIN discord_user AS duser ON msg.user_id = duser.userid -- only fetch messages for linked discord users
|
||||
LEFT JOIN discord_message_content AS c ON c.message_id = msg.id
|
||||
handmade_discordmessage AS msg
|
||||
JOIN handmade_discorduser AS duser ON msg.user_id = duser.userid -- only fetch messages for linked discord users
|
||||
LEFT JOIN handmade_discordmessagecontent AS c ON c.message_id = msg.id
|
||||
WHERE
|
||||
c.last_content IS NULL
|
||||
AND msg.guild_id = $1
|
||||
|
@ -91,10 +95,10 @@ func fetchMissingContent(ctx context.Context, dbConn *pgxpool.Pool) {
|
|||
return
|
||||
}
|
||||
|
||||
if len(messagesWithoutContent) > 0 {
|
||||
log.Info().Msgf("There are %d Discord messages without content, fetching their content now...", len(messagesWithoutContent))
|
||||
if len(imessagesWithoutContent) > 0 {
|
||||
log.Info().Msgf("There are %d Discord messages without content, fetching their content now...", len(imessagesWithoutContent))
|
||||
msgloop:
|
||||
for _, msg := range messagesWithoutContent {
|
||||
for _, imsg := range imessagesWithoutContent {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Info().Msg("Scrape was canceled")
|
||||
|
@ -102,6 +106,8 @@ func fetchMissingContent(ctx context.Context, dbConn *pgxpool.Pool) {
|
|||
default:
|
||||
}
|
||||
|
||||
msg := imsg.(*query).Message
|
||||
|
||||
discordMsg, err := GetChannelMessage(ctx, msg.ChannelID, msg.ID)
|
||||
if errors.Is(err, NotFound) {
|
||||
// This message has apparently been deleted; delete it from our database
|
||||
|
|
|
@ -165,10 +165,10 @@ func InternMessage(
|
|||
dbConn db.ConnOrTx,
|
||||
msg *Message,
|
||||
) error {
|
||||
_, err := db.QueryOne[models.DiscordMessage](ctx, dbConn,
|
||||
_, err := db.QueryOne(ctx, dbConn, models.DiscordMessage{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM discord_message
|
||||
FROM handmade_discordmessage
|
||||
WHERE id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
|
@ -190,7 +190,7 @@ func InternMessage(
|
|||
|
||||
_, err = dbConn.Exec(ctx,
|
||||
`
|
||||
INSERT INTO discord_message (id, channel_id, guild_id, url, user_id, sent_at, snippet_created)
|
||||
INSERT INTO handmade_discordmessage (id, channel_id, guild_id, url, user_id, sent_at, snippet_created)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`,
|
||||
msg.ID,
|
||||
|
@ -219,14 +219,15 @@ type InternedMessage struct {
|
|||
}
|
||||
|
||||
func FetchInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msgId string) (*InternedMessage, error) {
|
||||
interned, err := db.QueryOne[InternedMessage](ctx, dbConn,
|
||||
result, err := db.QueryOne(ctx, dbConn, InternedMessage{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
discord_message AS message
|
||||
LEFT JOIN discord_message_content AS content ON content.message_id = message.id
|
||||
LEFT JOIN discord_user AS duser ON duser.userid = message.user_id
|
||||
LEFT JOIN hmn_user AS hmnuser ON hmnuser.id = duser.hmn_user_id
|
||||
handmade_discordmessage AS message
|
||||
LEFT JOIN handmade_discordmessagecontent AS content ON content.message_id = message.id
|
||||
LEFT JOIN handmade_discorduser AS duser ON duser.userid = message.user_id
|
||||
LEFT JOIN auth_user AS hmnuser ON hmnuser.id = duser.hmn_user_id
|
||||
LEFT JOIN handmade_asset AS hmnuser_avatar ON hmnuser_avatar.id = hmnuser.avatar_asset_id
|
||||
WHERE message.id = $1
|
||||
`,
|
||||
msgId,
|
||||
|
@ -234,6 +235,8 @@ func FetchInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msgId string)
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
interned := result.(*InternedMessage)
|
||||
return interned, nil
|
||||
}
|
||||
|
||||
|
@ -280,10 +283,10 @@ func HandleInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message
|
|||
}
|
||||
|
||||
func DeleteInternedMessage(ctx context.Context, dbConn db.ConnOrTx, interned *InternedMessage) error {
|
||||
snippet, err := db.QueryOne[models.Snippet](ctx, dbConn,
|
||||
isnippet, err := db.QueryOne(ctx, dbConn, models.Snippet{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM snippet
|
||||
FROM handmade_snippet
|
||||
WHERE discord_message_id = $1
|
||||
`,
|
||||
interned.Message.ID,
|
||||
|
@ -291,15 +294,19 @@ func DeleteInternedMessage(ctx context.Context, dbConn db.ConnOrTx, interned *In
|
|||
if err != nil && !errors.Is(err, db.NotFound) {
|
||||
return oops.New(err, "failed to fetch snippet for discord message")
|
||||
}
|
||||
var snippet *models.Snippet
|
||||
if !errors.Is(err, db.NotFound) {
|
||||
snippet = isnippet.(*models.Snippet)
|
||||
}
|
||||
|
||||
// NOTE(asaf): Also deletes the following through a db cascade:
|
||||
// * discord_message_attachment
|
||||
// * discord_message_content
|
||||
// * discord_message_embed
|
||||
// * handmade_discordmessageattachment
|
||||
// * handmade_discordmessagecontent
|
||||
// * handmade_discordmessageembed
|
||||
// DOES NOT DELETE ASSETS FOR CONTENT/EMBEDS
|
||||
_, err = dbConn.Exec(ctx,
|
||||
`
|
||||
DELETE FROM discord_message
|
||||
DELETE FROM handmade_discordmessage
|
||||
WHERE id = $1
|
||||
`,
|
||||
interned.Message.ID,
|
||||
|
@ -311,7 +318,7 @@ func DeleteInternedMessage(ctx context.Context, dbConn db.ConnOrTx, interned *In
|
|||
// NOTE(asaf): Does not delete asset!
|
||||
_, err = dbConn.Exec(ctx,
|
||||
`
|
||||
DELETE FROM snippet
|
||||
DELETE FROM handmade_snippet
|
||||
WHERE id = $1
|
||||
`,
|
||||
snippet.ID,
|
||||
|
@ -346,7 +353,7 @@ func SaveMessageContents(
|
|||
if msg.OriginalHasFields("content") {
|
||||
_, err := dbConn.Exec(ctx,
|
||||
`
|
||||
INSERT INTO discord_message_content (message_id, discord_id, last_content)
|
||||
INSERT INTO handmade_discordmessagecontent (message_id, discord_id, last_content)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (message_id) DO UPDATE SET
|
||||
discord_id = EXCLUDED.discord_id,
|
||||
|
@ -360,20 +367,20 @@ func SaveMessageContents(
|
|||
return oops.New(err, "failed to create or update message contents")
|
||||
}
|
||||
|
||||
content, err := db.QueryOne[models.DiscordMessageContent](ctx, dbConn,
|
||||
icontent, err := db.QueryOne(ctx, dbConn, models.DiscordMessageContent{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
discord_message_content
|
||||
handmade_discordmessagecontent
|
||||
WHERE
|
||||
discord_message_content.message_id = $1
|
||||
handmade_discordmessagecontent.message_id = $1
|
||||
`,
|
||||
interned.Message.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to fetch message contents")
|
||||
}
|
||||
interned.MessageContent = content
|
||||
interned.MessageContent = icontent.(*models.DiscordMessageContent)
|
||||
}
|
||||
|
||||
// Save attachments
|
||||
|
@ -388,10 +395,10 @@ func SaveMessageContents(
|
|||
|
||||
// Save / delete embeds
|
||||
if msg.OriginalHasFields("embeds") {
|
||||
numSavedEmbeds, err := db.QueryOneScalar[int](ctx, dbConn,
|
||||
numSavedEmbeds, err := db.QueryInt(ctx, dbConn,
|
||||
`
|
||||
SELECT COUNT(*)
|
||||
FROM discord_message_embed
|
||||
FROM handmade_discordmessageembed
|
||||
WHERE message_id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
|
@ -411,7 +418,7 @@ func SaveMessageContents(
|
|||
// Embeds were removed from the message
|
||||
_, err := dbConn.Exec(ctx,
|
||||
`
|
||||
DELETE FROM discord_message_embed
|
||||
DELETE FROM handmade_discordmessageembed
|
||||
WHERE message_id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
|
@ -465,16 +472,16 @@ func saveAttachment(
|
|||
hmnUserID int,
|
||||
discordMessageID string,
|
||||
) (*models.DiscordMessageAttachment, error) {
|
||||
existing, err := db.QueryOne[models.DiscordMessageAttachment](ctx, tx,
|
||||
iexisting, err := db.QueryOne(ctx, tx, models.DiscordMessageAttachment{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM discord_message_attachment
|
||||
FROM handmade_discordmessageattachment
|
||||
WHERE id = $1
|
||||
`,
|
||||
attachment.ID,
|
||||
)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
return iexisting.(*models.DiscordMessageAttachment), nil
|
||||
} else if errors.Is(err, db.NotFound) {
|
||||
// this is fine, just create it
|
||||
} else {
|
||||
|
@ -516,7 +523,7 @@ func saveAttachment(
|
|||
// TODO(db): RETURNING plz thanks
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO discord_message_attachment (id, asset_id, message_id)
|
||||
INSERT INTO handmade_discordmessageattachment (id, asset_id, message_id)
|
||||
VALUES ($1, $2, $3)
|
||||
`,
|
||||
attachment.ID,
|
||||
|
@ -527,10 +534,10 @@ func saveAttachment(
|
|||
return nil, oops.New(err, "failed to save Discord attachment data")
|
||||
}
|
||||
|
||||
discordAttachment, err := db.QueryOne[models.DiscordMessageAttachment](ctx, tx,
|
||||
iDiscordAttachment, err := db.QueryOne(ctx, tx, models.DiscordMessageAttachment{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM discord_message_attachment
|
||||
FROM handmade_discordmessageattachment
|
||||
WHERE id = $1
|
||||
`,
|
||||
attachment.ID,
|
||||
|
@ -539,7 +546,7 @@ func saveAttachment(
|
|||
return nil, oops.New(err, "failed to fetch new Discord attachment data")
|
||||
}
|
||||
|
||||
return discordAttachment, nil
|
||||
return iDiscordAttachment.(*models.DiscordMessageAttachment), nil
|
||||
}
|
||||
|
||||
// Saves an embed from Discord. NOTE: This is _not_ idempotent, so only call it
|
||||
|
@ -614,7 +621,7 @@ func saveEmbed(
|
|||
var savedEmbedId int
|
||||
err = tx.QueryRow(ctx,
|
||||
`
|
||||
INSERT INTO discord_message_embed (title, description, url, message_id, image_id, video_id)
|
||||
INSERT INTO handmade_discordmessageembed (title, description, url, message_id, image_id, video_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
`,
|
||||
|
@ -629,10 +636,10 @@ func saveEmbed(
|
|||
return nil, oops.New(err, "failed to insert new embed")
|
||||
}
|
||||
|
||||
discordEmbed, err := db.QueryOne[models.DiscordMessageEmbed](ctx, tx,
|
||||
iDiscordEmbed, err := db.QueryOne(ctx, tx, models.DiscordMessageEmbed{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM discord_message_embed
|
||||
FROM handmade_discordmessageembed
|
||||
WHERE id = $1
|
||||
`,
|
||||
savedEmbedId,
|
||||
|
@ -641,14 +648,14 @@ func saveEmbed(
|
|||
return nil, oops.New(err, "failed to fetch new Discord embed data")
|
||||
}
|
||||
|
||||
return discordEmbed, nil
|
||||
return iDiscordEmbed.(*models.DiscordMessageEmbed), nil
|
||||
}
|
||||
|
||||
func FetchSnippetForMessage(ctx context.Context, dbConn db.ConnOrTx, msgID string) (*models.Snippet, error) {
|
||||
snippet, err := db.QueryOne[models.Snippet](ctx, dbConn,
|
||||
iresult, err := db.QueryOne(ctx, dbConn, models.Snippet{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM snippet
|
||||
FROM handmade_snippet
|
||||
WHERE discord_message_id = $1
|
||||
`,
|
||||
msgID,
|
||||
|
@ -662,7 +669,7 @@ func FetchSnippetForMessage(ctx context.Context, dbConn db.ConnOrTx, msgID strin
|
|||
}
|
||||
}
|
||||
|
||||
return snippet, nil
|
||||
return iresult.(*models.Snippet), nil
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -711,7 +718,7 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
|
|||
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
UPDATE snippet
|
||||
UPDATE handmade_snippet
|
||||
SET
|
||||
description = $1,
|
||||
_description_html = $2
|
||||
|
@ -740,7 +747,7 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
|
|||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO snippet (url, "when", description, _description_html, asset_id, discord_message_id, owner_id)
|
||||
INSERT INTO handmade_snippet (url, "when", description, _description_html, asset_id, discord_message_id, owner_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`,
|
||||
url,
|
||||
|
@ -762,7 +769,7 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
|
|||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE discord_message
|
||||
UPDATE handmade_discordmessage
|
||||
SET snippet_created = TRUE
|
||||
WHERE id = $1
|
||||
`,
|
||||
|
@ -798,12 +805,15 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
|
|||
projectIDs[i] = p.Project.ID
|
||||
}
|
||||
|
||||
userTags, err := db.Query[models.Tag](ctx, tx,
|
||||
type tagsRow struct {
|
||||
Tag models.Tag `db:"tags"`
|
||||
}
|
||||
iUserTags, err := db.Query(ctx, tx, tagsRow{},
|
||||
`
|
||||
SELECT $columns{tag}
|
||||
SELECT $columns
|
||||
FROM
|
||||
tag
|
||||
JOIN project ON project.tag = tag.id
|
||||
tags
|
||||
JOIN handmade_project AS project ON project.tag = tags.id
|
||||
WHERE
|
||||
project.id = ANY ($1)
|
||||
`,
|
||||
|
@ -813,7 +823,8 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
|
|||
return oops.New(err, "failed to fetch tags for user projects")
|
||||
}
|
||||
|
||||
for _, tag := range userTags {
|
||||
for _, itag := range iUserTags {
|
||||
tag := itag.(*tagsRow).Tag
|
||||
allTags = append(allTags, tag.ID)
|
||||
for _, messageTag := range messageTags {
|
||||
if strings.EqualFold(tag.Text, messageTag) {
|
||||
|
@ -824,7 +835,7 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
|
|||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
DELETE FROM snippet_tag
|
||||
DELETE FROM snippet_tags
|
||||
WHERE
|
||||
snippet_id = $1
|
||||
AND tag_id = ANY ($2)
|
||||
|
@ -839,7 +850,7 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
|
|||
for _, tagID := range desiredTags {
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO snippet_tag (snippet_id, tag_id)
|
||||
INSERT INTO snippet_tags (snippet_id, tag_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
`,
|
||||
|
@ -879,10 +890,10 @@ var RESnippetableUrl = regexp.MustCompile(`^https?://(youtu\.be|(www\.)?youtube\
|
|||
|
||||
func getSnippetAssetOrUrl(ctx context.Context, tx db.ConnOrTx, msg *models.DiscordMessage) (*uuid.UUID, *string, error) {
|
||||
// Check attachments
|
||||
attachments, err := db.Query[models.DiscordMessageAttachment](ctx, tx,
|
||||
attachments, err := db.Query(ctx, tx, models.DiscordMessageAttachment{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM discord_message_attachment
|
||||
FROM handmade_discordmessageattachment
|
||||
WHERE message_id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
|
@ -890,15 +901,16 @@ func getSnippetAssetOrUrl(ctx context.Context, tx db.ConnOrTx, msg *models.Disco
|
|||
if err != nil {
|
||||
return nil, nil, oops.New(err, "failed to fetch message attachments")
|
||||
}
|
||||
for _, attachment := range attachments {
|
||||
for _, iattachment := range attachments {
|
||||
attachment := iattachment.(*models.DiscordMessageAttachment)
|
||||
return &attachment.AssetID, nil, nil
|
||||
}
|
||||
|
||||
// Check embeds
|
||||
embeds, err := db.Query[models.DiscordMessageEmbed](ctx, tx,
|
||||
embeds, err := db.Query(ctx, tx, models.DiscordMessageEmbed{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM discord_message_embed
|
||||
FROM handmade_discordmessageembed
|
||||
WHERE message_id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
|
@ -906,7 +918,8 @@ func getSnippetAssetOrUrl(ctx context.Context, tx db.ConnOrTx, msg *models.Disco
|
|||
if err != nil {
|
||||
return nil, nil, oops.New(err, "failed to fetch discord embeds")
|
||||
}
|
||||
for _, embed := range embeds {
|
||||
for _, iembed := range embeds {
|
||||
embed := iembed.(*models.DiscordMessageEmbed)
|
||||
if embed.VideoID != nil {
|
||||
return embed.VideoID, nil, nil
|
||||
} else if embed.ImageID != nil {
|
||||
|
|
|
@ -184,26 +184,8 @@ func GetGuildMember(ctx context.Context, guildID, userID string) (*GuildMember,
|
|||
return &msg, nil
|
||||
}
|
||||
|
||||
type MentionType string
|
||||
|
||||
const (
|
||||
MentionTypeUsers MentionType = "users"
|
||||
MentionTypeRoles = "roles"
|
||||
MentionTypeEveryone = "everyone"
|
||||
)
|
||||
|
||||
type MessageAllowedMentions struct {
|
||||
Parse []MentionType `json:"parse"`
|
||||
}
|
||||
|
||||
const (
|
||||
FlagSuppressEmbeds int = 1 << 2
|
||||
)
|
||||
|
||||
type CreateMessageRequest struct {
|
||||
Content string `json:"content"`
|
||||
Flags int `json:"flags,omitempty"`
|
||||
AllowedMentions *MessageAllowedMentions `json:"allowed_mentions,omitempty"`
|
||||
}
|
||||
|
||||
func CreateMessage(ctx context.Context, channelID string, payloadJSON string, files ...FileUpload) (*Message, error) {
|
||||
|
@ -244,44 +226,6 @@ func CreateMessage(ctx context.Context, channelID string, payloadJSON string, fi
|
|||
return &msg, nil
|
||||
}
|
||||
|
||||
func EditMessage(ctx context.Context, channelID string, messageID string, payloadJSON string, files ...FileUpload) (*Message, error) {
|
||||
const name = "Edit Message"
|
||||
|
||||
contentType, body := makeNewMessageBody(payloadJSON, files)
|
||||
|
||||
path := fmt.Sprintf("/channels/%s/messages/%s", channelID, messageID)
|
||||
res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
|
||||
req := makeRequest(ctx, http.MethodPatch, path, body)
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
return req
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode >= 400 {
|
||||
logErrorResponse(ctx, name, res, "")
|
||||
return nil, oops.New(nil, "received error from Discord")
|
||||
}
|
||||
|
||||
// Maybe in the future we could more nicely handle errors like "bad channel",
|
||||
// but honestly what are the odds that we mess that up...
|
||||
|
||||
bodyBytes, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var msg Message
|
||||
err = json.Unmarshal(bodyBytes, &msg)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to unmarshal Discord message")
|
||||
}
|
||||
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
func DeleteMessage(ctx context.Context, channelID string, messageID string) error {
|
||||
const name = "Delete Message"
|
||||
|
||||
|
|
|
@ -1,139 +0,0 @@
|
|||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/hmndata"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
)
|
||||
|
||||
// NOTE(asaf): Updates or creates a discord message according to the following rules:
|
||||
// Create when:
|
||||
// * No previous message exists
|
||||
// * We have non-zero live streamers
|
||||
// * Message exists, but we're adding a new streamer that wasn't in the existing message
|
||||
// * Message exists, but is not the most recent message in the channel
|
||||
// Update otherwise
|
||||
// That way we ensure that the message doesn't get scrolled offscreen, and the
|
||||
// new message indicator for the channel doesn't trigger when a streamer goes offline or
|
||||
// updates the stream title.
|
||||
// NOTE(asaf): No-op if StreamsChannelID is not specified in the config
|
||||
func UpdateStreamers(ctx context.Context, dbConn db.ConnOrTx, streamers []hmndata.StreamDetails) error {
|
||||
if len(config.Config.Discord.StreamsChannelID) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
livestreamMessage, err := hmndata.FetchPersistentVar[hmndata.DiscordLivestreamMessage](
|
||||
ctx,
|
||||
dbConn,
|
||||
hmndata.VarNameDiscordLivestreamMessage,
|
||||
)
|
||||
editExisting := true
|
||||
if err != nil {
|
||||
if err == db.NotFound {
|
||||
editExisting = false
|
||||
} else {
|
||||
return oops.New(err, "failed to fetch last message persistent var from db")
|
||||
}
|
||||
}
|
||||
|
||||
if editExisting {
|
||||
_, err := GetChannelMessage(ctx, config.Config.Discord.StreamsChannelID, livestreamMessage.MessageID)
|
||||
if err != nil {
|
||||
if err == NotFound {
|
||||
editExisting = false
|
||||
} else {
|
||||
oops.New(err, "failed to fetch existing message from discord")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if editExisting {
|
||||
existingStreamers := livestreamMessage.Streamers
|
||||
for _, s := range streamers {
|
||||
found := false
|
||||
for _, es := range existingStreamers {
|
||||
if es.Username == s.Username {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
editExisting = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if editExisting && len(streamers) > 0 {
|
||||
messages, err := GetChannelMessages(ctx, config.Config.Discord.StreamsChannelID, GetChannelMessagesInput{
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to fetch messages from discord")
|
||||
}
|
||||
if len(messages) == 0 || messages[0].ID != livestreamMessage.MessageID {
|
||||
editExisting = false
|
||||
}
|
||||
}
|
||||
|
||||
messageContent := ""
|
||||
if len(streamers) == 0 {
|
||||
messageContent = "No one is currently streaming."
|
||||
} else {
|
||||
var builder strings.Builder
|
||||
for _, s := range streamers {
|
||||
builder.WriteString(fmt.Sprintf(":red_circle: **%s** is live: <https://twitch.tv/%s>\n> _%s_\nStarted <t:%d:R>\n\n", s.Username, s.Username, s.Title, s.StartTime.Unix()))
|
||||
}
|
||||
messageContent = builder.String()
|
||||
}
|
||||
|
||||
msgJson, err := json.Marshal(CreateMessageRequest{
|
||||
Content: messageContent,
|
||||
Flags: FlagSuppressEmbeds,
|
||||
AllowedMentions: &MessageAllowedMentions{},
|
||||
})
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to marshal discord message")
|
||||
}
|
||||
|
||||
newMessageID := ""
|
||||
if editExisting {
|
||||
updatedMessage, err := EditMessage(ctx, config.Config.Discord.StreamsChannelID, livestreamMessage.MessageID, string(msgJson))
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to update discord message for streams channel")
|
||||
}
|
||||
|
||||
newMessageID = updatedMessage.ID
|
||||
} else {
|
||||
if livestreamMessage != nil {
|
||||
err = DeleteMessage(ctx, config.Config.Discord.StreamsChannelID, livestreamMessage.MessageID)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to delete existing discord message from streams channel")
|
||||
}
|
||||
}
|
||||
|
||||
sentMessage, err := CreateMessage(ctx, config.Config.Discord.StreamsChannelID, string(msgJson))
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to create discord message for streams channel")
|
||||
}
|
||||
|
||||
newMessageID = sentMessage.ID
|
||||
}
|
||||
|
||||
data := hmndata.DiscordLivestreamMessage{
|
||||
MessageID: newMessageID,
|
||||
Streamers: streamers,
|
||||
}
|
||||
err = hmndata.StorePersistentVar(ctx, dbConn, hmndata.VarNameDiscordLivestreamMessage, &data)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to store persistent var for discord streams")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -106,8 +106,8 @@ func renderTemplate(name string, data interface{}) (string, error) {
|
|||
}
|
||||
|
||||
func sendMail(toAddress, toName, subject, contentHtml string) error {
|
||||
if config.Config.Email.ForceToAddress != "" {
|
||||
toAddress = config.Config.Email.ForceToAddress
|
||||
if config.Config.Email.OverrideRecipientEmail != "" {
|
||||
toAddress = config.Config.Email.OverrideRecipientEmail
|
||||
}
|
||||
contents := prepMailContents(
|
||||
makeHeaderAddress(toAddress, toName),
|
||||
|
|
|
@ -1,86 +0,0 @@
|
|||
package hmndata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
)
|
||||
|
||||
type PersistentVarName string
|
||||
|
||||
const (
|
||||
VarNameDiscordLivestreamMessage PersistentVarName = "discord_livestream_message"
|
||||
)
|
||||
|
||||
type StreamDetails struct {
|
||||
Username string `json:"username"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type DiscordLivestreamMessage struct {
|
||||
MessageID string `json:"message_id"`
|
||||
Streamers []StreamDetails `json:"streamers"`
|
||||
}
|
||||
|
||||
// NOTE(asaf): Returns db.NotFound if the variable isn't in the db.
|
||||
func FetchPersistentVar[T any](
|
||||
ctx context.Context,
|
||||
dbConn db.ConnOrTx,
|
||||
varName PersistentVarName,
|
||||
) (*T, error) {
|
||||
persistentVar, err := db.QueryOne[models.PersistentVar](ctx, dbConn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM persistent_var
|
||||
WHERE name = $1
|
||||
`,
|
||||
varName,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jsonString := persistentVar.Value
|
||||
var result T
|
||||
err = json.Unmarshal([]byte(jsonString), &result)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to unmarshal persistent var value")
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func StorePersistentVar[T any](
|
||||
ctx context.Context,
|
||||
dbConn db.ConnOrTx,
|
||||
name PersistentVarName,
|
||||
value *T,
|
||||
) error {
|
||||
jsonString, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to marshal variable")
|
||||
}
|
||||
|
||||
_, err = dbConn.Exec(ctx,
|
||||
`
|
||||
INSERT INTO persistent_var (name, value)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
value = EXCLUDED.value
|
||||
`,
|
||||
name,
|
||||
jsonString,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to insert var to db")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -71,7 +71,7 @@ func FetchProjects(
|
|||
Project models.Project `db:"project"`
|
||||
LogoLightAsset *models.Asset `db:"logolight_asset"`
|
||||
LogoDarkAsset *models.Asset `db:"logodark_asset"`
|
||||
Tag *models.Tag `db:"tag"`
|
||||
Tag *models.Tag `db:"tags"`
|
||||
}
|
||||
|
||||
// Fetch all valid projects (not yet subject to user permission checks)
|
||||
|
@ -82,17 +82,17 @@ func FetchProjects(
|
|||
qb.Add(`
|
||||
SELECT DISTINCT ON (project.id) $columns
|
||||
FROM
|
||||
project
|
||||
LEFT JOIN asset AS logolight_asset ON logolight_asset.id = project.logolight_asset_id
|
||||
LEFT JOIN asset AS logodark_asset ON logodark_asset.id = project.logodark_asset_id
|
||||
LEFT JOIN tag ON project.tag = tag.id
|
||||
handmade_project AS project
|
||||
LEFT JOIN handmade_asset AS logolight_asset ON logolight_asset.id = project.logolight_asset_id
|
||||
LEFT JOIN handmade_asset AS logodark_asset ON logodark_asset.id = project.logodark_asset_id
|
||||
LEFT JOIN tags ON project.tag = tags.id
|
||||
`)
|
||||
if len(q.OwnerIDs) > 0 {
|
||||
qb.Add(
|
||||
`
|
||||
JOIN (
|
||||
SELECT project_id, array_agg(user_id) AS owner_ids
|
||||
FROM user_project
|
||||
FROM handmade_user_projects
|
||||
WHERE user_id = ANY ($?)
|
||||
GROUP BY project_id
|
||||
) AS owner_filter ON project.id = owner_filter.project_id
|
||||
|
@ -140,15 +140,15 @@ func FetchProjects(
|
|||
}
|
||||
|
||||
// Do the query
|
||||
projectRows, err := db.Query[projectRow](ctx, dbConn, qb.String(), qb.Args()...)
|
||||
iprojects, err := db.Query(ctx, dbConn, projectRow{}, qb.String(), qb.Args()...)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch projects")
|
||||
}
|
||||
|
||||
// Fetch project owners to do permission checks
|
||||
projectIds := make([]int, len(projectRows))
|
||||
for i, p := range projectRows {
|
||||
projectIds[i] = p.Project.ID
|
||||
projectIds := make([]int, len(iprojects))
|
||||
for i, iproject := range iprojects {
|
||||
projectIds[i] = iproject.(*projectRow).Project.ID
|
||||
}
|
||||
projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds)
|
||||
if err != nil {
|
||||
|
@ -156,7 +156,8 @@ func FetchProjects(
|
|||
}
|
||||
|
||||
var res []ProjectAndStuff
|
||||
for i, p := range projectRows {
|
||||
for i, iproject := range iprojects {
|
||||
row := iproject.(*projectRow)
|
||||
owners := projectOwners[i].Owners
|
||||
|
||||
/*
|
||||
|
@ -190,10 +191,10 @@ func FetchProjects(
|
|||
}
|
||||
|
||||
projectGenerallyVisible := true &&
|
||||
p.Project.Lifecycle.In(models.VisibleProjectLifecycles) &&
|
||||
!p.Project.Hidden &&
|
||||
(!p.Project.Personal || allOwnersApproved || p.Project.IsHMN())
|
||||
if p.Project.IsHMN() {
|
||||
row.Project.Lifecycle.In(models.VisibleProjectLifecycles) &&
|
||||
!row.Project.Hidden &&
|
||||
(!row.Project.Personal || allOwnersApproved || row.Project.IsHMN())
|
||||
if row.Project.IsHMN() {
|
||||
projectGenerallyVisible = true // hard override
|
||||
}
|
||||
|
||||
|
@ -204,11 +205,11 @@ func FetchProjects(
|
|||
|
||||
if projectVisible {
|
||||
res = append(res, ProjectAndStuff{
|
||||
Project: p.Project,
|
||||
LogoLightAsset: p.LogoLightAsset,
|
||||
LogoDarkAsset: p.LogoDarkAsset,
|
||||
Project: row.Project,
|
||||
LogoLightAsset: row.LogoLightAsset,
|
||||
LogoDarkAsset: row.LogoDarkAsset,
|
||||
Owners: owners,
|
||||
Tag: p.Tag,
|
||||
Tag: row.Tag,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -333,10 +334,10 @@ func FetchMultipleProjectsOwners(
|
|||
UserID int `db:"user_id"`
|
||||
ProjectID int `db:"project_id"`
|
||||
}
|
||||
userProjects, err := db.Query[userProject](ctx, tx,
|
||||
iuserprojects, err := db.Query(ctx, tx, userProject{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM user_project
|
||||
FROM handmade_user_projects
|
||||
WHERE project_id = ANY($1)
|
||||
`,
|
||||
projectIds,
|
||||
|
@ -347,7 +348,9 @@ func FetchMultipleProjectsOwners(
|
|||
|
||||
// Get the unique user IDs from this set and fetch the users from the db
|
||||
var userIds []int
|
||||
for _, userProject := range userProjects {
|
||||
for _, iuserproject := range iuserprojects {
|
||||
userProject := iuserproject.(*userProject)
|
||||
|
||||
addUserId := true
|
||||
for _, uid := range userIds {
|
||||
if uid == userProject.UserID {
|
||||
|
@ -358,10 +361,19 @@ func FetchMultipleProjectsOwners(
|
|||
userIds = append(userIds, userProject.UserID)
|
||||
}
|
||||
}
|
||||
users, err := FetchUsers(ctx, tx, nil, UsersQuery{
|
||||
UserIDs: userIds,
|
||||
AnyStatus: true,
|
||||
})
|
||||
type userQuery struct {
|
||||
User models.User `db:"auth_user"`
|
||||
}
|
||||
iusers, err := db.Query(ctx, tx, userQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM auth_user
|
||||
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
|
||||
WHERE
|
||||
auth_user.id = ANY($1)
|
||||
`,
|
||||
userIds,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch users for projects")
|
||||
}
|
||||
|
@ -371,7 +383,9 @@ func FetchMultipleProjectsOwners(
|
|||
for i, pid := range projectIds {
|
||||
res[i] = ProjectOwners{ProjectID: pid}
|
||||
}
|
||||
for _, userProject := range userProjects {
|
||||
for _, iuserproject := range iuserprojects {
|
||||
userProject := iuserproject.(*userProject)
|
||||
|
||||
// Get a pointer to the existing record in the result
|
||||
var projectOwners *ProjectOwners
|
||||
for i := range res {
|
||||
|
@ -382,9 +396,10 @@ func FetchMultipleProjectsOwners(
|
|||
|
||||
// Get the full user record we fetched
|
||||
var user *models.User
|
||||
for _, u := range users {
|
||||
for _, iuser := range iusers {
|
||||
u := iuser.(*userQuery).User
|
||||
if u.ID == userProject.UserID {
|
||||
user = u
|
||||
user = &u
|
||||
}
|
||||
}
|
||||
if user == nil {
|
||||
|
@ -458,9 +473,9 @@ func SetProjectTag(
|
|||
resultTag = p.Tag
|
||||
} else if p.Project.TagID == nil {
|
||||
// Create a tag
|
||||
tag, err := db.QueryOne[models.Tag](ctx, tx,
|
||||
itag, err := db.QueryOne(ctx, tx, models.Tag{},
|
||||
`
|
||||
INSERT INTO tag (text) VALUES ($1)
|
||||
INSERT INTO tags (text) VALUES ($1)
|
||||
RETURNING $columns
|
||||
`,
|
||||
tagText,
|
||||
|
@ -468,12 +483,12 @@ func SetProjectTag(
|
|||
if err != nil {
|
||||
return nil, oops.New(err, "failed to create new tag for project")
|
||||
}
|
||||
resultTag = tag
|
||||
resultTag = itag.(*models.Tag)
|
||||
|
||||
// Attach it to the project
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE project
|
||||
UPDATE handmade_project
|
||||
SET tag = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
|
@ -484,11 +499,11 @@ func SetProjectTag(
|
|||
}
|
||||
} else {
|
||||
// Update the text of an existing one
|
||||
tag, err := db.QueryOne[models.Tag](ctx, tx,
|
||||
itag, err := db.QueryOne(ctx, tx, models.Tag{},
|
||||
`
|
||||
UPDATE tag
|
||||
UPDATE tags
|
||||
SET text = $1
|
||||
WHERE id = (SELECT tag FROM project WHERE id = $2)
|
||||
WHERE id = (SELECT tag FROM handmade_project WHERE id = $2)
|
||||
RETURNING $columns
|
||||
`,
|
||||
tagText, projectID,
|
||||
|
@ -496,7 +511,7 @@ func SetProjectTag(
|
|||
if err != nil {
|
||||
return nil, oops.New(err, "failed to update existing tag")
|
||||
}
|
||||
resultTag = tag
|
||||
resultTag = itag.(*models.Tag)
|
||||
}
|
||||
|
||||
err = tx.Commit(ctx)
|
||||
|
|
|
@ -44,14 +44,17 @@ func FetchSnippets(
|
|||
|
||||
if len(q.Tags) > 0 {
|
||||
// Get snippet IDs with this tag, then use that in the main query
|
||||
snippetIDs, err := db.QueryScalar[int](ctx, tx,
|
||||
type snippetIDRow struct {
|
||||
SnippetID int `db:"snippet_id"`
|
||||
}
|
||||
iSnippetIDs, err := db.Query(ctx, tx, snippetIDRow{},
|
||||
`
|
||||
SELECT DISTINCT snippet_id
|
||||
FROM
|
||||
snippet_tag
|
||||
JOIN tag ON snippet_tag.tag_id = tag.id
|
||||
snippet_tags
|
||||
JOIN tags ON snippet_tags.tag_id = tags.id
|
||||
WHERE
|
||||
tag.id = ANY ($1)
|
||||
tags.id = ANY ($1)
|
||||
`,
|
||||
q.Tags,
|
||||
)
|
||||
|
@ -60,11 +63,14 @@ func FetchSnippets(
|
|||
}
|
||||
|
||||
// special early-out: no snippets found for these tags at all
|
||||
if len(snippetIDs) == 0 {
|
||||
if len(iSnippetIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
q.IDs = snippetIDs
|
||||
q.IDs = make([]int, len(iSnippetIDs))
|
||||
for i := range iSnippetIDs {
|
||||
q.IDs[i] = iSnippetIDs[i].(*snippetIDRow).SnippetID
|
||||
}
|
||||
}
|
||||
|
||||
var qb db.QueryBuilder
|
||||
|
@ -72,11 +78,11 @@ func FetchSnippets(
|
|||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
snippet
|
||||
LEFT JOIN hmn_user AS owner ON snippet.owner_id = owner.id
|
||||
LEFT JOIN asset AS owner_avatar ON owner_avatar.id = owner.avatar_asset_id
|
||||
LEFT JOIN asset ON snippet.asset_id = asset.id
|
||||
LEFT JOIN discord_message ON snippet.discord_message_id = discord_message.id
|
||||
handmade_snippet AS snippet
|
||||
LEFT JOIN auth_user AS owner ON snippet.owner_id = owner.id
|
||||
LEFT JOIN handmade_asset AS owner_avatar ON owner_avatar.id = owner.avatar_asset_id
|
||||
LEFT JOIN handmade_asset AS asset ON snippet.asset_id = asset.id
|
||||
LEFT JOIN handmade_discordmessage AS discord_message ON snippet.discord_message_id = discord_message.id
|
||||
WHERE
|
||||
TRUE
|
||||
`,
|
||||
|
@ -119,14 +125,16 @@ func FetchSnippets(
|
|||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
||||
}
|
||||
|
||||
results, err := db.Query[resultRow](ctx, tx, qb.String(), qb.Args()...)
|
||||
iresults, err := db.Query(ctx, tx, resultRow{}, qb.String(), qb.Args()...)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch threads")
|
||||
}
|
||||
|
||||
result := make([]SnippetAndStuff, len(results)) // allocate extra space because why not
|
||||
snippetIDs := make([]int, len(results))
|
||||
for i, row := range results {
|
||||
result := make([]SnippetAndStuff, len(iresults)) // allocate extra space because why not
|
||||
snippetIDs := make([]int, len(iresults))
|
||||
for i, iresult := range iresults {
|
||||
row := *iresult.(*resultRow)
|
||||
|
||||
result[i] = SnippetAndStuff{
|
||||
Snippet: row.Snippet,
|
||||
Owner: row.Owner,
|
||||
|
@ -139,17 +147,17 @@ func FetchSnippets(
|
|||
|
||||
// Fetch tags
|
||||
type snippetTagRow struct {
|
||||
SnippetID int `db:"snippet_tag.snippet_id"`
|
||||
Tag *models.Tag `db:"tag"`
|
||||
SnippetID int `db:"snippet_tags.snippet_id"`
|
||||
Tag *models.Tag `db:"tags"`
|
||||
}
|
||||
snippetTags, err := db.Query[snippetTagRow](ctx, tx,
|
||||
iSnippetTags, err := db.Query(ctx, tx, snippetTagRow{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
snippet_tag
|
||||
JOIN tag ON snippet_tag.tag_id = tag.id
|
||||
snippet_tags
|
||||
JOIN tags ON snippet_tags.tag_id = tags.id
|
||||
WHERE
|
||||
snippet_tag.snippet_id = ANY($1)
|
||||
snippet_tags.snippet_id = ANY($1)
|
||||
`,
|
||||
snippetIDs,
|
||||
)
|
||||
|
@ -162,7 +170,8 @@ func FetchSnippets(
|
|||
for i := range result {
|
||||
resultBySnippetId[result[i].Snippet.ID] = &result[i]
|
||||
}
|
||||
for _, snippetTag := range snippetTags {
|
||||
for _, iSnippetTag := range iSnippetTags {
|
||||
snippetTag := iSnippetTag.(*snippetTagRow)
|
||||
item := resultBySnippetId[snippetTag.SnippetID]
|
||||
item.Tags = append(item.Tags, snippetTag.Tag)
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ func FetchTags(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) ([]*models.T
|
|||
qb.Add(
|
||||
`
|
||||
SELECT $columns
|
||||
FROM tag
|
||||
FROM tags
|
||||
WHERE
|
||||
TRUE
|
||||
`,
|
||||
|
@ -40,11 +40,18 @@ func FetchTags(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) ([]*models.T
|
|||
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
|
||||
}
|
||||
|
||||
tags, err := db.Query[models.Tag](ctx, dbConn, qb.String(), qb.Args()...)
|
||||
itags, err := db.Query(ctx, dbConn, models.Tag{}, qb.String(), qb.Args()...)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch tags")
|
||||
}
|
||||
return tags, nil
|
||||
|
||||
res := make([]*models.Tag, len(itags))
|
||||
for i, itag := range itags {
|
||||
tag := itag.(*models.Tag)
|
||||
res[i] = tag
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func FetchTag(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) (*models.Tag, error) {
|
||||
|
|
|
@ -71,21 +71,21 @@ func FetchThreads(
|
|||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
thread
|
||||
JOIN project ON thread.project_id = project.id
|
||||
JOIN post AS first_post ON first_post.id = thread.first_id
|
||||
JOIN post AS last_post ON last_post.id = thread.last_id
|
||||
JOIN post_version AS first_version ON first_version.id = first_post.current_id
|
||||
JOIN post_version AS last_version ON last_version.id = last_post.current_id
|
||||
LEFT JOIN hmn_user AS first_author ON first_author.id = first_post.author_id
|
||||
LEFT JOIN asset AS first_author_avatar ON first_author_avatar.id = first_author.avatar_asset_id
|
||||
LEFT JOIN hmn_user AS last_author ON last_author.id = last_post.author_id
|
||||
LEFT JOIN asset AS last_author_avatar ON last_author_avatar.id = last_author.avatar_asset_id
|
||||
LEFT JOIN thread_last_read_info AS tlri ON (
|
||||
handmade_thread AS thread
|
||||
JOIN handmade_project AS project ON thread.project_id = project.id
|
||||
JOIN handmade_post AS first_post ON first_post.id = thread.first_id
|
||||
JOIN handmade_post AS last_post ON last_post.id = thread.last_id
|
||||
JOIN handmade_postversion AS first_version ON first_version.id = first_post.current_id
|
||||
JOIN handmade_postversion AS last_version ON last_version.id = last_post.current_id
|
||||
LEFT JOIN auth_user AS first_author ON first_author.id = first_post.author_id
|
||||
LEFT JOIN handmade_asset AS first_author_avatar ON first_author_avatar.id = first_author.avatar_asset_id
|
||||
LEFT JOIN auth_user AS last_author ON last_author.id = last_post.author_id
|
||||
LEFT JOIN handmade_asset AS last_author_avatar ON last_author_avatar.id = last_author.avatar_asset_id
|
||||
LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
|
||||
tlri.thread_id = thread.id
|
||||
AND tlri.user_id = $?
|
||||
)
|
||||
LEFT JOIN subforum_last_read_info AS slri ON (
|
||||
LEFT JOIN handmade_subforumlastreadinfo AS slri ON (
|
||||
slri.subforum_id = thread.subforum_id
|
||||
AND slri.user_id = $?
|
||||
)
|
||||
|
@ -141,25 +141,18 @@ func FetchThreads(
|
|||
|
||||
type resultRow struct {
|
||||
ThreadAndStuff
|
||||
FirstPostAuthorAvatar *models.Asset `db:"first_author_avatar"`
|
||||
LastPostAuthorAvatar *models.Asset `db:"last_author_avatar"`
|
||||
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
|
||||
ForumLastReadTime *time.Time `db:"slri.lastread"`
|
||||
}
|
||||
|
||||
rows, err := db.Query[resultRow](ctx, dbConn, qb.String(), qb.Args()...)
|
||||
iresults, err := db.Query(ctx, dbConn, resultRow{}, qb.String(), qb.Args()...)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch threads")
|
||||
}
|
||||
|
||||
result := make([]ThreadAndStuff, len(rows))
|
||||
for i, row := range rows {
|
||||
if row.FirstPostAuthor != nil {
|
||||
row.FirstPostAuthor.AvatarAsset = row.FirstPostAuthorAvatar
|
||||
}
|
||||
if row.LastPostAuthor != nil {
|
||||
row.LastPostAuthor.AvatarAsset = row.LastPostAuthorAvatar
|
||||
}
|
||||
result := make([]ThreadAndStuff, len(iresults))
|
||||
for i, iresult := range iresults {
|
||||
row := *iresult.(*resultRow)
|
||||
|
||||
hasRead := false
|
||||
if currentUser != nil && currentUser.MarkedAllReadAt.After(row.LastPost.PostDate) {
|
||||
|
@ -228,11 +221,11 @@ func CountThreads(
|
|||
`
|
||||
SELECT COUNT(*)
|
||||
FROM
|
||||
thread
|
||||
JOIN project ON thread.project_id = project.id
|
||||
JOIN post AS first_post ON first_post.id = thread.first_id
|
||||
LEFT JOIN hmn_user AS first_author ON first_author.id = first_post.author_id
|
||||
LEFT JOIN asset AS first_author_avatar ON first_author_avatar.id = first_author.avatar_asset_id
|
||||
handmade_thread AS thread
|
||||
JOIN handmade_project AS project ON thread.project_id = project.id
|
||||
JOIN handmade_post AS first_post ON first_post.id = thread.first_id
|
||||
LEFT JOIN auth_user AS first_author ON first_author.id = first_post.author_id
|
||||
LEFT JOIN handmade_asset AS first_author_avatar ON first_author_avatar.id = first_author.avatar_asset_id
|
||||
WHERE
|
||||
NOT thread.deleted
|
||||
AND ( -- project has valid lifecycle
|
||||
|
@ -270,7 +263,7 @@ func CountThreads(
|
|||
)
|
||||
}
|
||||
|
||||
count, err := db.QueryOneScalar[int](ctx, dbConn, qb.String(), qb.Args()...)
|
||||
count, err := db.QueryInt(ctx, dbConn, qb.String(), qb.Args()...)
|
||||
if err != nil {
|
||||
return 0, oops.New(err, "failed to fetch count of threads")
|
||||
}
|
||||
|
@ -329,9 +322,6 @@ func FetchPosts(
|
|||
|
||||
type resultRow struct {
|
||||
PostAndStuff
|
||||
AuthorAvatar *models.Asset `db:"author_avatar"`
|
||||
EditorAvatar *models.Asset `db:"editor_avatar"`
|
||||
ReplyAuthorAvatar *models.Asset `db:"reply_author_avatar"`
|
||||
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
|
||||
ForumLastReadTime *time.Time `db:"slri.lastread"`
|
||||
}
|
||||
|
@ -340,28 +330,28 @@ func FetchPosts(
|
|||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
post
|
||||
JOIN thread ON post.thread_id = thread.id
|
||||
JOIN project ON post.project_id = project.id
|
||||
JOIN post_version AS ver ON ver.id = post.current_id
|
||||
LEFT JOIN hmn_user AS author ON author.id = post.author_id
|
||||
LEFT JOIN asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
|
||||
LEFT JOIN hmn_user AS editor ON ver.editor_id = editor.id
|
||||
LEFT JOIN asset AS editor_avatar ON editor_avatar.id = editor.avatar_asset_id
|
||||
LEFT JOIN thread_last_read_info AS tlri ON (
|
||||
handmade_post AS post
|
||||
JOIN handmade_thread AS thread ON post.thread_id = thread.id
|
||||
JOIN handmade_project AS project ON post.project_id = project.id
|
||||
JOIN handmade_postversion AS ver ON ver.id = post.current_id
|
||||
LEFT JOIN auth_user AS author ON author.id = post.author_id
|
||||
LEFT JOIN handmade_asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
|
||||
LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
|
||||
LEFT JOIN handmade_asset AS editor_avatar ON editor_avatar.id = editor.avatar_asset_id
|
||||
LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
|
||||
tlri.thread_id = thread.id
|
||||
AND tlri.user_id = $?
|
||||
)
|
||||
LEFT JOIN subforum_last_read_info AS slri ON (
|
||||
LEFT JOIN handmade_subforumlastreadinfo AS slri ON (
|
||||
slri.subforum_id = thread.subforum_id
|
||||
AND slri.user_id = $?
|
||||
)
|
||||
-- Unconditionally fetch reply info, but make sure to check it
|
||||
-- later and possibly remove these fields if the permission
|
||||
-- check fails.
|
||||
LEFT JOIN post AS reply_post ON reply_post.id = post.reply_id
|
||||
LEFT JOIN hmn_user AS reply_author ON reply_post.author_id = reply_author.id
|
||||
LEFT JOIN asset AS reply_author_avatar ON reply_author_avatar.id = reply_author.avatar_asset_id
|
||||
LEFT JOIN handmade_post AS reply_post ON reply_post.id = post.reply_id
|
||||
LEFT JOIN auth_user AS reply_author ON reply_post.author_id = reply_author.id
|
||||
LEFT JOIN handmade_asset AS reply_author_avatar ON reply_author_avatar.id = reply_author.avatar_asset_id
|
||||
WHERE
|
||||
NOT thread.deleted
|
||||
AND NOT post.deleted
|
||||
|
@ -415,22 +405,14 @@ func FetchPosts(
|
|||
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
|
||||
}
|
||||
|
||||
rows, err := db.Query[resultRow](ctx, dbConn, qb.String(), qb.Args()...)
|
||||
iresults, err := db.Query(ctx, dbConn, resultRow{}, qb.String(), qb.Args()...)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch posts")
|
||||
}
|
||||
|
||||
result := make([]PostAndStuff, len(rows))
|
||||
for i, row := range rows {
|
||||
if row.Author != nil {
|
||||
row.Author.AvatarAsset = row.AuthorAvatar
|
||||
}
|
||||
if row.Editor != nil {
|
||||
row.Editor.AvatarAsset = row.EditorAvatar
|
||||
}
|
||||
if row.ReplyAuthor != nil {
|
||||
row.ReplyAuthor.AvatarAsset = row.ReplyAuthorAvatar
|
||||
}
|
||||
result := make([]PostAndStuff, len(iresults))
|
||||
for i, iresult := range iresults {
|
||||
row := *iresult.(*resultRow)
|
||||
|
||||
hasRead := false
|
||||
if currentUser != nil && currentUser.MarkedAllReadAt.After(row.Post.PostDate) {
|
||||
|
@ -567,11 +549,11 @@ func CountPosts(
|
|||
`
|
||||
SELECT COUNT(*)
|
||||
FROM
|
||||
post
|
||||
JOIN thread ON post.thread_id = thread.id
|
||||
JOIN project ON post.project_id = project.id
|
||||
LEFT JOIN hmn_user AS author ON author.id = post.author_id
|
||||
LEFT JOIN asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
|
||||
handmade_post AS post
|
||||
JOIN handmade_thread AS thread ON post.thread_id = thread.id
|
||||
JOIN handmade_project AS project ON post.project_id = project.id
|
||||
LEFT JOIN auth_user AS author ON author.id = post.author_id
|
||||
LEFT JOIN handmade_asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
|
||||
WHERE
|
||||
NOT thread.deleted
|
||||
AND NOT post.deleted
|
||||
|
@ -613,7 +595,7 @@ func CountPosts(
|
|||
)
|
||||
}
|
||||
|
||||
count, err := db.QueryOneScalar[int](ctx, dbConn, qb.String(), qb.Args()...)
|
||||
count, err := db.QueryInt(ctx, dbConn, qb.String(), qb.Args()...)
|
||||
if err != nil {
|
||||
return 0, oops.New(err, "failed to count posts")
|
||||
}
|
||||
|
@ -626,11 +608,14 @@ func UserCanEditPost(ctx context.Context, connOrTx db.ConnOrTx, user models.User
|
|||
return true
|
||||
}
|
||||
|
||||
authorID, err := db.QueryOneScalar[*int](ctx, connOrTx,
|
||||
type postResult struct {
|
||||
AuthorID *int `db:"post.author_id"`
|
||||
}
|
||||
iresult, err := db.QueryOne(ctx, connOrTx, postResult{},
|
||||
`
|
||||
SELECT post.author_id
|
||||
SELECT $columns
|
||||
FROM
|
||||
post
|
||||
handmade_post AS post
|
||||
WHERE
|
||||
post.id = $1
|
||||
AND NOT post.deleted
|
||||
|
@ -644,8 +629,9 @@ func UserCanEditPost(ctx context.Context, connOrTx db.ConnOrTx, user models.User
|
|||
panic(oops.New(err, "failed to get author of post when checking permissions"))
|
||||
}
|
||||
}
|
||||
result := iresult.(*postResult)
|
||||
|
||||
return authorID != nil && *authorID == user.ID
|
||||
return result.AuthorID != nil && *result.AuthorID == user.ID
|
||||
}
|
||||
|
||||
func CreateNewPost(
|
||||
|
@ -661,7 +647,7 @@ func CreateNewPost(
|
|||
// Create post
|
||||
err := tx.QueryRow(ctx,
|
||||
`
|
||||
INSERT INTO post (postdate, thread_id, thread_type, current_id, author_id, project_id, reply_id, preview)
|
||||
INSERT INTO handmade_post (postdate, thread_id, thread_type, current_id, author_id, project_id, reply_id, preview)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`,
|
||||
|
@ -702,7 +688,7 @@ func CreateNewPost(
|
|||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE project
|
||||
UPDATE handmade_project
|
||||
SET `+updates+`
|
||||
WHERE
|
||||
id = $1
|
||||
|
@ -723,11 +709,11 @@ func DeletePost(
|
|||
FirstPostID int `db:"first_id"`
|
||||
Deleted bool `db:"deleted"`
|
||||
}
|
||||
info, err := db.QueryOne[threadInfo](ctx, tx,
|
||||
ti, err := db.QueryOne(ctx, tx, threadInfo{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
thread
|
||||
handmade_thread AS thread
|
||||
WHERE
|
||||
thread.id = $1
|
||||
`,
|
||||
|
@ -736,6 +722,7 @@ func DeletePost(
|
|||
if err != nil {
|
||||
panic(oops.New(err, "failed to fetch thread info"))
|
||||
}
|
||||
info := ti.(*threadInfo)
|
||||
if info.Deleted {
|
||||
return true
|
||||
}
|
||||
|
@ -745,7 +732,7 @@ func DeletePost(
|
|||
// Just delete the whole thread and all its posts.
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE thread
|
||||
UPDATE handmade_thread
|
||||
SET deleted = TRUE
|
||||
WHERE id = $1
|
||||
`,
|
||||
|
@ -753,7 +740,7 @@ func DeletePost(
|
|||
)
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE post
|
||||
UPDATE handmade_post
|
||||
SET deleted = TRUE
|
||||
WHERE thread_id = $1
|
||||
`,
|
||||
|
@ -765,7 +752,7 @@ func DeletePost(
|
|||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE post
|
||||
UPDATE handmade_post
|
||||
SET deleted = TRUE
|
||||
WHERE
|
||||
id = $1
|
||||
|
@ -811,7 +798,7 @@ func CreatePostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedConte
|
|||
// Create post version
|
||||
err := tx.QueryRow(ctx,
|
||||
`
|
||||
INSERT INTO post_version (post_id, text_raw, text_parsed, ip, date, edit_reason, editor_id)
|
||||
INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date, edit_reason, editor_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
`,
|
||||
|
@ -830,7 +817,7 @@ func CreatePostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedConte
|
|||
// Update post with version id and preview
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE post
|
||||
UPDATE handmade_post
|
||||
SET current_id = $1, preview = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
|
@ -846,7 +833,7 @@ func CreatePostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedConte
|
|||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
DELETE FROM post_asset_usage
|
||||
DELETE FROM handmade_post_asset_usage
|
||||
WHERE post_id = $1
|
||||
`,
|
||||
postId,
|
||||
|
@ -861,10 +848,13 @@ func CreatePostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedConte
|
|||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
assetIDs, err := db.QueryScalar[uuid.UUID](ctx, tx,
|
||||
type assetId struct {
|
||||
AssetID uuid.UUID `db:"id"`
|
||||
}
|
||||
assetResult, err := db.Query(ctx, tx, assetId{},
|
||||
`
|
||||
SELECT id
|
||||
FROM asset
|
||||
SELECT $columns
|
||||
FROM handmade_asset
|
||||
WHERE s3_key = ANY($1)
|
||||
`,
|
||||
keys,
|
||||
|
@ -875,11 +865,11 @@ func CreatePostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedConte
|
|||
|
||||
var values [][]interface{}
|
||||
|
||||
for _, assetID := range assetIDs {
|
||||
values = append(values, []interface{}{postId, assetID})
|
||||
for _, asset := range assetResult {
|
||||
values = append(values, []interface{}{postId, asset.(*assetId).AssetID})
|
||||
}
|
||||
|
||||
_, err = tx.CopyFrom(ctx, pgx.Identifier{"post_asset_usage"}, []string{"post_id", "asset_id"}, pgx.CopyFromRows(values))
|
||||
_, err = tx.CopyFrom(ctx, pgx.Identifier{"handmade_post_asset_usage"}, []string{"post_id", "asset_id"}, pgx.CopyFromRows(values))
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to insert post asset usage"))
|
||||
}
|
||||
|
@ -895,11 +885,11 @@ Ensures that the first_id and last_id on the thread are still good.
|
|||
Returns errThreadEmpty if the thread contains no visible posts any more.
|
||||
You should probably mark the thread as deleted in this case.
|
||||
*/
|
||||
func FixThreadPostIds(ctx context.Context, conn db.ConnOrTx, threadId int) error {
|
||||
posts, err := db.Query[models.Post](ctx, conn,
|
||||
func FixThreadPostIds(ctx context.Context, tx pgx.Tx, threadId int) error {
|
||||
postsIter, err := db.Query(ctx, tx, models.Post{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM post
|
||||
FROM handmade_post
|
||||
WHERE
|
||||
thread_id = $1
|
||||
AND NOT deleted
|
||||
|
@ -911,7 +901,9 @@ func FixThreadPostIds(ctx context.Context, conn db.ConnOrTx, threadId int) error
|
|||
}
|
||||
|
||||
var firstPost, lastPost *models.Post
|
||||
for _, post := range posts {
|
||||
for _, ipost := range postsIter {
|
||||
post := ipost.(*models.Post)
|
||||
|
||||
if firstPost == nil || post.PostDate.Before(firstPost.PostDate) {
|
||||
firstPost = post
|
||||
}
|
||||
|
@ -924,9 +916,9 @@ func FixThreadPostIds(ctx context.Context, conn db.ConnOrTx, threadId int) error
|
|||
return errThreadEmpty
|
||||
}
|
||||
|
||||
_, err = conn.Exec(ctx,
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE thread
|
||||
UPDATE handmade_thread
|
||||
SET first_id = $1, last_id = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
|
|
|
@ -22,19 +22,22 @@ type TwitchStreamer struct {
|
|||
var twitchRegex = regexp.MustCompile(`twitch\.tv/(?P<login>[^/]+)$`)
|
||||
|
||||
func FetchTwitchStreamers(ctx context.Context, dbConn db.ConnOrTx) ([]TwitchStreamer, error) {
|
||||
dbStreamers, err := db.Query[models.Link](ctx, dbConn,
|
||||
type linkResult struct {
|
||||
Link models.Link `db:"link"`
|
||||
}
|
||||
streamers, err := db.Query(ctx, dbConn, linkResult{},
|
||||
`
|
||||
SELECT $columns{link}
|
||||
SELECT $columns
|
||||
FROM
|
||||
link
|
||||
LEFT JOIN hmn_user AS link_owner ON link_owner.id = link.user_id
|
||||
handmade_links AS link
|
||||
LEFT JOIN auth_user AS link_owner ON link_owner.id = link.user_id
|
||||
WHERE
|
||||
url ~* 'twitch\.tv/([^/]+)$' AND
|
||||
((link.user_id IS NOT NULL AND link_owner.status = $1) OR (link.project_id IS NOT NULL AND
|
||||
(SELECT COUNT(*)
|
||||
FROM
|
||||
user_project AS hup
|
||||
JOIN hmn_user AS project_owner ON project_owner.id = hup.user_id
|
||||
handmade_user_projects AS hup
|
||||
JOIN auth_user AS project_owner ON project_owner.id = hup.user_id
|
||||
WHERE
|
||||
hup.project_id = link.project_id AND
|
||||
project_owner.status != $1
|
||||
|
@ -46,8 +49,10 @@ func FetchTwitchStreamers(ctx context.Context, dbConn db.ConnOrTx) ([]TwitchStre
|
|||
return nil, oops.New(err, "failed to fetch twitch links")
|
||||
}
|
||||
|
||||
result := make([]TwitchStreamer, 0, len(dbStreamers))
|
||||
for _, dbStreamer := range dbStreamers {
|
||||
result := make([]TwitchStreamer, 0, len(streamers))
|
||||
for _, s := range streamers {
|
||||
dbStreamer := s.(*linkResult).Link
|
||||
|
||||
streamer := TwitchStreamer{
|
||||
UserID: dbStreamer.UserID,
|
||||
ProjectID: dbStreamer.ProjectID,
|
||||
|
@ -76,11 +81,11 @@ func FetchTwitchStreamers(ctx context.Context, dbConn db.ConnOrTx) ([]TwitchStre
|
|||
}
|
||||
|
||||
func FetchTwitchLoginsForUserOrProject(ctx context.Context, dbConn db.ConnOrTx, userId *int, projectId *int) ([]string, error) {
|
||||
links, err := db.Query[models.Link](ctx, dbConn,
|
||||
links, err := db.Query(ctx, dbConn, models.Link{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
link
|
||||
handmade_links AS link
|
||||
WHERE
|
||||
url ~* 'twitch\.tv/([^/]+)$'
|
||||
AND ((user_id = $1 AND project_id IS NULL) OR (user_id IS NULL AND project_id = $2))
|
||||
|
@ -95,7 +100,8 @@ func FetchTwitchLoginsForUserOrProject(ctx context.Context, dbConn db.ConnOrTx,
|
|||
result := make([]string, 0, len(links))
|
||||
|
||||
for _, l := range links {
|
||||
match := twitchRegex.FindStringSubmatch(l.URL)
|
||||
url := l.(*models.Link).URL
|
||||
match := twitchRegex.FindStringSubmatch(url)
|
||||
if match != nil {
|
||||
login := strings.ToLower(match[twitchRegex.SubexpIndex("login")])
|
||||
result = append(result, login)
|
||||
|
|
|
@ -1,154 +0,0 @@
|
|||
package hmndata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/perf"
|
||||
)
|
||||
|
||||
type UsersQuery struct {
|
||||
// Ignored when using FetchUser
|
||||
UserIDs []int // if empty, all users
|
||||
Usernames []string // if empty, all users
|
||||
|
||||
// Flags to modify behavior
|
||||
AnyStatus bool // Bypasses shadowban system
|
||||
}
|
||||
|
||||
/*
|
||||
Fetches users and related models from the database according to all the given
|
||||
query params. For the most correct results, provide as much information as you have
|
||||
on hand.
|
||||
*/
|
||||
func FetchUsers(
|
||||
ctx context.Context,
|
||||
dbConn db.ConnOrTx,
|
||||
currentUser *models.User,
|
||||
q UsersQuery,
|
||||
) ([]*models.User, error) {
|
||||
perf := perf.ExtractPerf(ctx)
|
||||
perf.StartBlock("SQL", "Fetch users")
|
||||
defer perf.EndBlock()
|
||||
|
||||
var currentUserID *int
|
||||
if currentUser != nil {
|
||||
currentUserID = ¤tUser.ID
|
||||
}
|
||||
|
||||
for i := range q.Usernames {
|
||||
q.Usernames[i] = strings.ToLower(q.Usernames[i])
|
||||
}
|
||||
|
||||
type userRow struct {
|
||||
User models.User `db:"hmn_user"`
|
||||
AvatarAsset *models.Asset `db:"avatar"`
|
||||
}
|
||||
|
||||
var qb db.QueryBuilder
|
||||
qb.Add(`
|
||||
SELECT $columns
|
||||
FROM
|
||||
hmn_user
|
||||
LEFT JOIN asset AS avatar ON avatar.id = hmn_user.avatar_asset_id
|
||||
WHERE
|
||||
TRUE
|
||||
`)
|
||||
if len(q.UserIDs) > 0 {
|
||||
qb.Add(`AND hmn_user.id = ANY($?)`, q.UserIDs)
|
||||
}
|
||||
if len(q.Usernames) > 0 {
|
||||
qb.Add(`AND LOWER(hmn_user.username) = ANY($?)`, q.Usernames)
|
||||
}
|
||||
if !q.AnyStatus {
|
||||
if currentUser == nil {
|
||||
qb.Add(`AND hmn_user.status = $?`, models.UserStatusApproved)
|
||||
} else if !currentUser.IsStaff {
|
||||
qb.Add(
|
||||
`
|
||||
AND (
|
||||
hmn_user.status = $? -- user is Approved
|
||||
OR hmn_user.id = $? -- getting self
|
||||
)
|
||||
`,
|
||||
models.UserStatusApproved,
|
||||
currentUserID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
userRows, err := db.Query[userRow](ctx, dbConn, qb.String(), qb.Args()...)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch users")
|
||||
}
|
||||
|
||||
result := make([]*models.User, len(userRows))
|
||||
for i, row := range userRows {
|
||||
user := row.User
|
||||
user.AvatarAsset = row.AvatarAsset
|
||||
result[i] = &user
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
/*
|
||||
Fetches a single user and related data. A wrapper around FetchUsers.
|
||||
As with FetchUsers, provide as much information as you know to get the
|
||||
most correct results.
|
||||
|
||||
Returns db.NotFound if no result is found.
|
||||
*/
|
||||
func FetchUser(
|
||||
ctx context.Context,
|
||||
dbConn db.ConnOrTx,
|
||||
currentUser *models.User,
|
||||
userID int,
|
||||
q UsersQuery,
|
||||
) (*models.User, error) {
|
||||
q.UserIDs = []int{userID}
|
||||
|
||||
res, err := FetchUsers(ctx, dbConn, currentUser, q)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch user")
|
||||
}
|
||||
|
||||
if len(res) == 0 {
|
||||
return nil, db.NotFound
|
||||
}
|
||||
|
||||
return res[0], nil
|
||||
}
|
||||
|
||||
/*
|
||||
Fetches a single user and related data. A wrapper around FetchUsers.
|
||||
As with FetchUsers, provide as much information as you know to get the
|
||||
most correct results.
|
||||
|
||||
Returns db.NotFound if no result is found.
|
||||
*/
|
||||
func FetchUserByUsername(
|
||||
ctx context.Context,
|
||||
dbConn db.ConnOrTx,
|
||||
currentUser *models.User,
|
||||
username string,
|
||||
q UsersQuery,
|
||||
) (*models.User, error) {
|
||||
q.Usernames = []string{username}
|
||||
|
||||
res, err := FetchUsers(ctx, dbConn, currentUser, q)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch user")
|
||||
}
|
||||
|
||||
if len(res) == 0 {
|
||||
return nil, db.NotFound
|
||||
}
|
||||
|
||||
return res[0], nil
|
||||
}
|
||||
|
||||
// NOTE(ben): Someday we can add CountUsers...I don't have a need for it right now.
|
|
@ -1,101 +0,0 @@
|
|||
package hmns3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/jobs"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
const dir = "./tmp/s3"
|
||||
|
||||
type server struct {
|
||||
log zerolog.Logger
|
||||
}
|
||||
|
||||
func StartServer(ctx context.Context) jobs.Job {
|
||||
if !config.Config.DigitalOcean.RunFakeServer {
|
||||
return jobs.Noop()
|
||||
}
|
||||
|
||||
utils.Must0(os.MkdirAll(dir, fs.ModePerm))
|
||||
|
||||
s := server{
|
||||
log: logging.ExtractLogger(ctx).With().
|
||||
Str("module", "S3 server").
|
||||
Logger(),
|
||||
}
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
s.getObject(w, r)
|
||||
} else if r.Method == http.MethodPut {
|
||||
s.putObject(w, r)
|
||||
} else {
|
||||
panic("Unimplemented method!")
|
||||
}
|
||||
})
|
||||
|
||||
job := jobs.New()
|
||||
srv := http.Server{
|
||||
Addr: config.Config.DigitalOcean.FakeAddr,
|
||||
}
|
||||
|
||||
s.log.Info().Msg("Starting local S3 server")
|
||||
go func() {
|
||||
defer job.Done()
|
||||
err := srv.ListenAndServe()
|
||||
if err != nil {
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
// This is normal and fine
|
||||
} else {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
s.log.Info().Msg("Shutting down local S3 server")
|
||||
srv.Shutdown(context.Background())
|
||||
}()
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
func (s *server) getObject(w http.ResponseWriter, r *http.Request) {
|
||||
bucket, key := bucketKey(r)
|
||||
|
||||
file := utils.Must1(os.Open(filepath.Join(dir, bucket, key)))
|
||||
io.Copy(w, file)
|
||||
}
|
||||
|
||||
func (s *server) putObject(w http.ResponseWriter, r *http.Request) {
|
||||
bucket, key := bucketKey(r)
|
||||
|
||||
w.Header().Set("Location", fmt.Sprintf("/%s", bucket))
|
||||
utils.Must0(os.MkdirAll(filepath.Join(dir, bucket), fs.ModePerm))
|
||||
if key != "" {
|
||||
file := utils.Must1(os.Create(filepath.Join(dir, bucket, key)))
|
||||
io.Copy(file, r.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func bucketKey(r *http.Request) (string, string) {
|
||||
slashIdx := strings.IndexByte(r.URL.Path[1:], '/')
|
||||
if slashIdx == -1 {
|
||||
return r.URL.Path[1:], ""
|
||||
} else {
|
||||
return r.URL.Path[1 : 1+slashIdx], strings.ReplaceAll(r.URL.Path[2+slashIdx:], "/", "~")
|
||||
}
|
||||
}
|
|
@ -26,18 +26,10 @@ func TestUrl(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
var hmn = HMNProjectContext
|
||||
var hero = UrlContext{
|
||||
PersonalProject: false,
|
||||
ProjectID: 2,
|
||||
ProjectSlug: "hero",
|
||||
ProjectName: "Handmade Hero",
|
||||
}
|
||||
|
||||
func TestHomepage(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildHomepage(), RegexHomepage, nil)
|
||||
AssertRegexMatch(t, hero.BuildHomepage(), RegexHomepage, nil)
|
||||
AssertSubdomain(t, hero.BuildHomepage(), "hero")
|
||||
AssertRegexMatch(t, BuildProjectHomepage("hero"), RegexHomepage, nil)
|
||||
AssertSubdomain(t, BuildProjectHomepage("hero"), "hero")
|
||||
}
|
||||
|
||||
func TestShowcase(t *testing.T) {
|
||||
|
@ -95,6 +87,7 @@ func TestPasswordReset(t *testing.T) {
|
|||
func TestStaticPages(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildManifesto(), RegexManifesto, nil)
|
||||
AssertRegexMatch(t, BuildAbout(), RegexAbout, nil)
|
||||
AssertRegexMatch(t, BuildCodeOfConduct(), RegexCodeOfConduct, nil)
|
||||
AssertRegexMatch(t, BuildCommunicationGuidelines(), RegexCommunicationGuidelines, nil)
|
||||
AssertRegexMatch(t, BuildContactPage(), RegexContactPage, nil)
|
||||
AssertRegexMatch(t, BuildMonthlyUpdatePolicy(), RegexMonthlyUpdatePolicy, nil)
|
||||
|
@ -140,12 +133,12 @@ func TestProjectNew(t *testing.T) {
|
|||
AssertRegexMatch(t, BuildProjectNew(), RegexProjectNew, nil)
|
||||
}
|
||||
|
||||
func TestPersonalProject(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildPersonalProject(123, "test"), RegexPersonalProject, nil)
|
||||
func TestProjectNotApproved(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildProjectNotApproved("test"), RegexProjectNotApproved, map[string]string{"slug": "test"})
|
||||
}
|
||||
|
||||
func TestProjectEdit(t *testing.T) {
|
||||
AssertRegexMatch(t, hero.BuildProjectEdit("foo"), RegexProjectEdit, nil)
|
||||
AssertRegexMatch(t, BuildProjectEdit("test", "foo"), RegexProjectEdit, map[string]string{"slug": "test"})
|
||||
}
|
||||
|
||||
func TestPodcast(t *testing.T) {
|
||||
|
@ -173,100 +166,101 @@ func TestPodcastRSS(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestForum(t *testing.T) {
|
||||
AssertRegexMatch(t, hmn.BuildForum(nil, 1), RegexForum, nil)
|
||||
AssertRegexMatch(t, hmn.BuildForum([]string{"wip"}, 2), RegexForum, map[string]string{"subforums": "wip", "page": "2"})
|
||||
AssertRegexMatch(t, hmn.BuildForum([]string{"sub", "wip"}, 2), RegexForum, map[string]string{"subforums": "sub/wip", "page": "2"})
|
||||
AssertSubdomain(t, hmn.BuildForum(nil, 1), "")
|
||||
AssertSubdomain(t, hero.BuildForum(nil, 1), "hero")
|
||||
assert.Panics(t, func() { hmn.BuildForum(nil, 0) })
|
||||
assert.Panics(t, func() { hmn.BuildForum([]string{"", "wip"}, 1) })
|
||||
assert.Panics(t, func() { hmn.BuildForum([]string{" ", "wip"}, 1) })
|
||||
assert.Panics(t, func() { hmn.BuildForum([]string{"wip/jobs"}, 1) })
|
||||
AssertRegexMatch(t, BuildForum("", nil, 1), RegexForum, nil)
|
||||
AssertRegexMatch(t, BuildForum("", []string{"wip"}, 2), RegexForum, map[string]string{"subforums": "wip", "page": "2"})
|
||||
AssertRegexMatch(t, BuildForum("", []string{"sub", "wip"}, 2), RegexForum, map[string]string{"subforums": "sub/wip", "page": "2"})
|
||||
AssertSubdomain(t, BuildForum("hmn", nil, 1), "")
|
||||
AssertSubdomain(t, BuildForum("", nil, 1), "")
|
||||
AssertSubdomain(t, BuildForum("hero", nil, 1), "hero")
|
||||
assert.Panics(t, func() { BuildForum("", nil, 0) })
|
||||
assert.Panics(t, func() { BuildForum("", []string{"", "wip"}, 1) })
|
||||
assert.Panics(t, func() { BuildForum("", []string{" ", "wip"}, 1) })
|
||||
assert.Panics(t, func() { BuildForum("", []string{"wip/jobs"}, 1) })
|
||||
}
|
||||
|
||||
func TestForumNewThread(t *testing.T) {
|
||||
AssertRegexMatch(t, hmn.BuildForumNewThread([]string{"sub", "wip"}, false), RegexForumNewThread, map[string]string{"subforums": "sub/wip"})
|
||||
AssertRegexMatch(t, hmn.BuildForumNewThread([]string{"sub", "wip"}, true), RegexForumNewThreadSubmit, map[string]string{"subforums": "sub/wip"})
|
||||
AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, false), RegexForumNewThread, map[string]string{"subforums": "sub/wip"})
|
||||
AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, true), RegexForumNewThreadSubmit, map[string]string{"subforums": "sub/wip"})
|
||||
}
|
||||
|
||||
func TestForumThread(t *testing.T) {
|
||||
AssertRegexMatch(t, hmn.BuildForumThread(nil, 1, "", 1), RegexForumThread, map[string]string{"threadid": "1"})
|
||||
AssertRegexMatch(t, hmn.BuildForumThread(nil, 1, "thread/title/123http://", 2), RegexForumThread, map[string]string{"threadid": "1", "page": "2"})
|
||||
AssertRegexMatch(t, hmn.BuildForumThreadWithPostHash(nil, 1, "thread/title/123http://", 2, 123), RegexForumThread, map[string]string{"threadid": "1", "page": "2"})
|
||||
AssertSubdomain(t, hero.BuildForumThread(nil, 1, "", 1), "hero")
|
||||
assert.Panics(t, func() { hmn.BuildForumThread(nil, -1, "", 1) })
|
||||
assert.Panics(t, func() { hmn.BuildForumThread(nil, 1, "", -1) })
|
||||
AssertRegexMatch(t, BuildForumThread("", nil, 1, "", 1), RegexForumThread, map[string]string{"threadid": "1"})
|
||||
AssertRegexMatch(t, BuildForumThread("", nil, 1, "thread/title/123http://", 2), RegexForumThread, map[string]string{"threadid": "1", "page": "2"})
|
||||
AssertRegexMatch(t, BuildForumThreadWithPostHash("", nil, 1, "thread/title/123http://", 2, 123), RegexForumThread, map[string]string{"threadid": "1", "page": "2"})
|
||||
AssertSubdomain(t, BuildForumThread("hero", nil, 1, "", 1), "hero")
|
||||
assert.Panics(t, func() { BuildForumThread("", nil, -1, "", 1) })
|
||||
assert.Panics(t, func() { BuildForumThread("", nil, 1, "", -1) })
|
||||
}
|
||||
|
||||
func TestForumPost(t *testing.T) {
|
||||
AssertRegexMatch(t, hmn.BuildForumPost(nil, 1, 2), RegexForumPost, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, hmn.BuildForumPost(nil, 1, 2), RegexForumThread)
|
||||
AssertSubdomain(t, hero.BuildForumPost(nil, 1, 2), "hero")
|
||||
assert.Panics(t, func() { hmn.BuildForumPost(nil, 1, -1) })
|
||||
AssertRegexMatch(t, BuildForumPost("", nil, 1, 2), RegexForumPost, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, BuildForumPost("", nil, 1, 2), RegexForumThread)
|
||||
AssertSubdomain(t, BuildForumPost("hero", nil, 1, 2), "hero")
|
||||
assert.Panics(t, func() { BuildForumPost("", nil, 1, -1) })
|
||||
}
|
||||
|
||||
func TestForumPostDelete(t *testing.T) {
|
||||
AssertRegexMatch(t, hmn.BuildForumPostDelete(nil, 1, 2), RegexForumPostDelete, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, hmn.BuildForumPostDelete(nil, 1, 2), RegexForumPost)
|
||||
AssertSubdomain(t, hero.BuildForumPostDelete(nil, 1, 2), "hero")
|
||||
AssertRegexMatch(t, BuildForumPostDelete("", nil, 1, 2), RegexForumPostDelete, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, BuildForumPostDelete("", nil, 1, 2), RegexForumPost)
|
||||
AssertSubdomain(t, BuildForumPostDelete("hero", nil, 1, 2), "hero")
|
||||
}
|
||||
|
||||
func TestForumPostEdit(t *testing.T) {
|
||||
AssertRegexMatch(t, hmn.BuildForumPostEdit(nil, 1, 2), RegexForumPostEdit, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, hmn.BuildForumPostEdit(nil, 1, 2), RegexForumPost)
|
||||
AssertSubdomain(t, hero.BuildForumPostEdit(nil, 1, 2), "hero")
|
||||
AssertRegexMatch(t, BuildForumPostEdit("", nil, 1, 2), RegexForumPostEdit, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, BuildForumPostEdit("", nil, 1, 2), RegexForumPost)
|
||||
AssertSubdomain(t, BuildForumPostEdit("hero", nil, 1, 2), "hero")
|
||||
}
|
||||
|
||||
func TestForumPostReply(t *testing.T) {
|
||||
AssertRegexMatch(t, hmn.BuildForumPostReply(nil, 1, 2), RegexForumPostReply, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, hmn.BuildForumPostReply(nil, 1, 2), RegexForumPost)
|
||||
AssertSubdomain(t, hero.BuildForumPostReply(nil, 1, 2), "hero")
|
||||
AssertRegexMatch(t, BuildForumPostReply("", nil, 1, 2), RegexForumPostReply, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, BuildForumPostReply("", nil, 1, 2), RegexForumPost)
|
||||
AssertSubdomain(t, BuildForumPostReply("hero", nil, 1, 2), "hero")
|
||||
}
|
||||
|
||||
func TestBlog(t *testing.T) {
|
||||
AssertRegexMatch(t, hmn.BuildBlog(1), RegexBlog, nil)
|
||||
AssertRegexMatch(t, hmn.BuildBlog(2), RegexBlog, map[string]string{"page": "2"})
|
||||
AssertSubdomain(t, hero.BuildBlog(1), "hero")
|
||||
AssertRegexMatch(t, BuildBlog("", 1), RegexBlog, nil)
|
||||
AssertRegexMatch(t, BuildBlog("", 2), RegexBlog, map[string]string{"page": "2"})
|
||||
AssertSubdomain(t, BuildBlog("hero", 1), "hero")
|
||||
}
|
||||
|
||||
func TestBlogNewThread(t *testing.T) {
|
||||
AssertRegexMatch(t, hmn.BuildBlogNewThread(), RegexBlogNewThread, nil)
|
||||
AssertSubdomain(t, hmn.BuildBlogNewThread(), "")
|
||||
AssertRegexMatch(t, hero.BuildBlogNewThread(), RegexBlogNewThread, nil)
|
||||
AssertSubdomain(t, hero.BuildBlogNewThread(), "hero")
|
||||
AssertRegexMatch(t, BuildBlogNewThread(""), RegexBlogNewThread, nil)
|
||||
AssertSubdomain(t, BuildBlogNewThread(""), "")
|
||||
AssertRegexMatch(t, BuildBlogNewThread("hero"), RegexBlogNewThread, nil)
|
||||
AssertSubdomain(t, BuildBlogNewThread("hero"), "hero")
|
||||
}
|
||||
|
||||
func TestBlogThread(t *testing.T) {
|
||||
AssertRegexMatch(t, hmn.BuildBlogThread(1, ""), RegexBlogThread, map[string]string{"threadid": "1"})
|
||||
AssertRegexMatch(t, hmn.BuildBlogThread(1, ""), RegexBlogThread, map[string]string{"threadid": "1"})
|
||||
AssertRegexMatch(t, hmn.BuildBlogThread(1, "title/bla/http://"), RegexBlogThread, map[string]string{"threadid": "1"})
|
||||
AssertRegexMatch(t, hmn.BuildBlogThreadWithPostHash(1, "title/bla/http://", 123), RegexBlogThread, map[string]string{"threadid": "1"})
|
||||
AssertRegexNoMatch(t, hmn.BuildBlogThread(1, ""), RegexBlog)
|
||||
AssertSubdomain(t, hero.BuildBlogThread(1, ""), "hero")
|
||||
AssertRegexMatch(t, BuildBlogThread("", 1, ""), RegexBlogThread, map[string]string{"threadid": "1"})
|
||||
AssertRegexMatch(t, BuildBlogThread("", 1, ""), RegexBlogThread, map[string]string{"threadid": "1"})
|
||||
AssertRegexMatch(t, BuildBlogThread("", 1, "title/bla/http://"), RegexBlogThread, map[string]string{"threadid": "1"})
|
||||
AssertRegexMatch(t, BuildBlogThreadWithPostHash("", 1, "title/bla/http://", 123), RegexBlogThread, map[string]string{"threadid": "1"})
|
||||
AssertRegexNoMatch(t, BuildBlogThread("", 1, ""), RegexBlog)
|
||||
AssertSubdomain(t, BuildBlogThread("hero", 1, ""), "hero")
|
||||
}
|
||||
|
||||
func TestBlogPost(t *testing.T) {
|
||||
AssertRegexMatch(t, hmn.BuildBlogPost(1, 2), RegexBlogPost, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, hmn.BuildBlogPost(1, 2), RegexBlogThread)
|
||||
AssertSubdomain(t, hero.BuildBlogPost(1, 2), "hero")
|
||||
AssertRegexMatch(t, BuildBlogPost("", 1, 2), RegexBlogPost, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, BuildBlogPost("", 1, 2), RegexBlogThread)
|
||||
AssertSubdomain(t, BuildBlogPost("hero", 1, 2), "hero")
|
||||
}
|
||||
|
||||
func TestBlogPostDelete(t *testing.T) {
|
||||
AssertRegexMatch(t, hmn.BuildBlogPostDelete(1, 2), RegexBlogPostDelete, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, hmn.BuildBlogPostDelete(1, 2), RegexBlogPost)
|
||||
AssertSubdomain(t, hero.BuildBlogPostDelete(1, 2), "hero")
|
||||
AssertRegexMatch(t, BuildBlogPostDelete("", 1, 2), RegexBlogPostDelete, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, BuildBlogPostDelete("", 1, 2), RegexBlogPost)
|
||||
AssertSubdomain(t, BuildBlogPostDelete("hero", 1, 2), "hero")
|
||||
}
|
||||
|
||||
func TestBlogPostEdit(t *testing.T) {
|
||||
AssertRegexMatch(t, hmn.BuildBlogPostEdit(1, 2), RegexBlogPostEdit, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, hmn.BuildBlogPostEdit(1, 2), RegexBlogPost)
|
||||
AssertSubdomain(t, hero.BuildBlogPostEdit(1, 2), "hero")
|
||||
AssertRegexMatch(t, BuildBlogPostEdit("", 1, 2), RegexBlogPostEdit, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, BuildBlogPostEdit("", 1, 2), RegexBlogPost)
|
||||
AssertSubdomain(t, BuildBlogPostEdit("hero", 1, 2), "hero")
|
||||
}
|
||||
|
||||
func TestBlogPostReply(t *testing.T) {
|
||||
AssertRegexMatch(t, hmn.BuildBlogPostReply(1, 2), RegexBlogPostReply, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, hmn.BuildBlogPostReply(1, 2), RegexBlogPost)
|
||||
AssertSubdomain(t, hero.BuildBlogPostReply(1, 2), "hero")
|
||||
AssertRegexMatch(t, BuildBlogPostReply("", 1, 2), RegexBlogPostReply, map[string]string{"threadid": "1", "postid": "2"})
|
||||
AssertRegexNoMatch(t, BuildBlogPostReply("", 1, 2), RegexBlogPost)
|
||||
AssertSubdomain(t, BuildBlogPostReply("hero", 1, 2), "hero")
|
||||
}
|
||||
|
||||
func TestLibrary(t *testing.T) {
|
||||
|
@ -286,20 +280,20 @@ func TestLibraryResource(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestEpisodeGuide(t *testing.T) {
|
||||
AssertRegexMatch(t, hero.BuildEpisodeList(""), RegexEpisodeList, map[string]string{"topic": ""})
|
||||
AssertRegexMatch(t, hero.BuildEpisodeList("code"), RegexEpisodeList, map[string]string{"topic": "code"})
|
||||
AssertSubdomain(t, hero.BuildEpisodeList("code"), "hero")
|
||||
AssertRegexMatch(t, BuildEpisodeList("hero", ""), RegexEpisodeList, map[string]string{"topic": ""})
|
||||
AssertRegexMatch(t, BuildEpisodeList("hero", "code"), RegexEpisodeList, map[string]string{"topic": "code"})
|
||||
AssertSubdomain(t, BuildEpisodeList("hero", "code"), "hero")
|
||||
|
||||
AssertRegexMatch(t, hero.BuildEpisode("code", "day001"), RegexEpisode, map[string]string{"topic": "code", "episode": "day001"})
|
||||
AssertSubdomain(t, hero.BuildEpisode("code", "day001"), "hero")
|
||||
AssertRegexMatch(t, BuildEpisode("hero", "code", "day001"), RegexEpisode, map[string]string{"topic": "code", "episode": "day001"})
|
||||
AssertSubdomain(t, BuildEpisode("hero", "code", "day001"), "hero")
|
||||
|
||||
AssertRegexMatch(t, hero.BuildCineraIndex("code"), RegexCineraIndex, map[string]string{"topic": "code"})
|
||||
AssertSubdomain(t, hero.BuildCineraIndex("code"), "hero")
|
||||
AssertRegexMatch(t, BuildCineraIndex("hero", "code"), RegexCineraIndex, map[string]string{"topic": "code"})
|
||||
AssertSubdomain(t, BuildCineraIndex("hero", "code"), "hero")
|
||||
}
|
||||
|
||||
func TestAssetUpload(t *testing.T) {
|
||||
AssertRegexMatch(t, hero.BuildAssetUpload(), RegexAssetUpload, nil)
|
||||
AssertSubdomain(t, hero.BuildAssetUpload(), "hero")
|
||||
AssertRegexMatch(t, BuildAssetUpload("hero"), RegexAssetUpload, nil)
|
||||
AssertSubdomain(t, BuildAssetUpload("hero"), "hero")
|
||||
}
|
||||
|
||||
func TestProjectCSS(t *testing.T) {
|
||||
|
@ -314,10 +308,6 @@ func TestAPICheckUsername(t *testing.T) {
|
|||
AssertRegexMatch(t, BuildAPICheckUsername(), RegexAPICheckUsername, nil)
|
||||
}
|
||||
|
||||
func TestTwitchEventSubCallback(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildTwitchEventSubCallback(), RegexTwitchEventSubCallback, nil)
|
||||
}
|
||||
|
||||
func TestPublic(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildPublic("test", false), RegexPublic, nil)
|
||||
AssertRegexMatch(t, BuildPublic("/test", true), RegexPublic, nil)
|
||||
|
@ -334,8 +324,8 @@ func TestPublic(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestForumMarkRead(t *testing.T) {
|
||||
AssertRegexMatch(t, hero.BuildForumMarkRead(5), RegexForumMarkRead, map[string]string{"sfid": "5"})
|
||||
AssertSubdomain(t, hero.BuildForumMarkRead(5), "hero")
|
||||
AssertRegexMatch(t, BuildForumMarkRead("hero", 5), RegexForumMarkRead, map[string]string{"sfid": "5"})
|
||||
AssertSubdomain(t, BuildForumMarkRead("hero", 5), "hero")
|
||||
}
|
||||
|
||||
func TestS3Asset(t *testing.T) {
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
package jobs
|
||||
|
||||
type Job struct {
|
||||
C <-chan struct{}
|
||||
rawC chan struct{}
|
||||
}
|
||||
|
||||
func New() Job {
|
||||
return newFromChannel(make(chan struct{}))
|
||||
}
|
||||
|
||||
func (j *Job) Done() {
|
||||
close(j.rawC)
|
||||
}
|
||||
|
||||
// Combines multiple jobs into one.
|
||||
func Zip(jobs ...Job) Job {
|
||||
out := make(chan struct{})
|
||||
go func() {
|
||||
for _, job := range jobs {
|
||||
<-job.C
|
||||
}
|
||||
close(out)
|
||||
}()
|
||||
return newFromChannel(out)
|
||||
}
|
||||
|
||||
// Returns a job that is already done.
|
||||
func Noop() Job {
|
||||
job := New()
|
||||
job.Done()
|
||||
return job
|
||||
}
|
||||
|
||||
func newFromChannel(c chan struct{}) Job {
|
||||
return Job{
|
||||
C: c,
|
||||
rawC: c,
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@ import (
|
|||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
@ -24,11 +24,6 @@ import (
|
|||
var listMigrations bool
|
||||
|
||||
func init() {
|
||||
dbCommand := &cobra.Command{
|
||||
Use: "db",
|
||||
Short: "Database-related commands",
|
||||
}
|
||||
|
||||
migrateCommand := &cobra.Command{
|
||||
Use: "migrate [target migration id]",
|
||||
Short: "Run database migrations",
|
||||
|
@ -69,15 +64,6 @@ func init() {
|
|||
},
|
||||
}
|
||||
|
||||
seedCommand := &cobra.Command{
|
||||
Use: "seed",
|
||||
Short: "Resets the db and populates it with sample data.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ResetDB()
|
||||
SampleSeed()
|
||||
},
|
||||
}
|
||||
|
||||
seedFromFileCommand := &cobra.Command{
|
||||
Use: "seedfile <filename>",
|
||||
Short: "Resets the db and runs the seed file.",
|
||||
|
@ -88,16 +74,13 @@ func init() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
ResetDB()
|
||||
SeedFromFile(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
website.WebsiteCommand.AddCommand(dbCommand)
|
||||
dbCommand.AddCommand(migrateCommand)
|
||||
dbCommand.AddCommand(makeMigrationCommand)
|
||||
dbCommand.AddCommand(seedCommand)
|
||||
dbCommand.AddCommand(seedFromFileCommand)
|
||||
website.WebsiteCommand.AddCommand(migrateCommand)
|
||||
website.WebsiteCommand.AddCommand(makeMigrationCommand)
|
||||
website.WebsiteCommand.AddCommand(seedFromFileCommand)
|
||||
}
|
||||
|
||||
func getSortedMigrationVersions() []types.MigrationVersion {
|
||||
|
@ -151,19 +134,10 @@ func ListMigrations() {
|
|||
}
|
||||
}
|
||||
|
||||
func LatestVersion() types.MigrationVersion {
|
||||
allVersions := getSortedMigrationVersions()
|
||||
return allVersions[len(allVersions)-1]
|
||||
}
|
||||
|
||||
// Migrates either forward or backward to the selected migration version. You probably want to
|
||||
// use LatestVersion to get the most recent migration.
|
||||
func Migrate(targetVersion types.MigrationVersion) {
|
||||
ctx := context.Background() // In the future, this could actually do something cool.
|
||||
|
||||
conn := db.NewConnWithConfig(config.PostgresConfig{
|
||||
LogLevel: pgx.LogLevelWarn,
|
||||
})
|
||||
conn := db.NewConn()
|
||||
defer conn.Close(ctx)
|
||||
|
||||
// create migration table
|
||||
|
@ -203,7 +177,7 @@ func Migrate(targetVersion types.MigrationVersion) {
|
|||
|
||||
allVersions := getSortedMigrationVersions()
|
||||
if targetVersion.IsZero() {
|
||||
targetVersion = LatestVersion()
|
||||
targetVersion = allVersions[len(allVersions)-1]
|
||||
}
|
||||
|
||||
currentIndex := -1
|
||||
|
@ -316,135 +290,84 @@ func MakeMigration(name, description string) {
|
|||
fmt.Println(path)
|
||||
}
|
||||
|
||||
func ResetDB() {
|
||||
fmt.Println("Resetting database...")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create the HMN database user
|
||||
{
|
||||
type pgCredentials struct {
|
||||
User string
|
||||
Password string
|
||||
}
|
||||
credentials := []pgCredentials{
|
||||
{config.Config.Postgres.User, config.Config.Postgres.Password}, // Existing HMN user
|
||||
{getSystemUsername(), ""}, // Postgres.app on Mac
|
||||
}
|
||||
|
||||
var workingCred pgCredentials
|
||||
var createUserConn *pgconn.PgConn
|
||||
var connErrors []error
|
||||
for _, cred := range credentials {
|
||||
// NOTE(asaf): We have to use the low-level API of pgconn, because the pgx Exec always wraps the query in a transaction.
|
||||
var err error
|
||||
createUserConn, err = connectLowLevel(ctx, cred.User, cred.Password)
|
||||
if err == nil {
|
||||
workingCred = cred
|
||||
break
|
||||
} else {
|
||||
connErrors = append(connErrors, err)
|
||||
}
|
||||
}
|
||||
if createUserConn == nil {
|
||||
fmt.Println("Failed to connect to the db to reset it.")
|
||||
fmt.Println("The following errors occurred for each attempted set of credentials:")
|
||||
for _, err := range connErrors {
|
||||
fmt.Printf("- %v\n", err)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("If this is a local development environment, please let us know what platform you")
|
||||
fmt.Println("are using and how you installed Postgres. We want to try and streamline the setup")
|
||||
fmt.Println("process for you.")
|
||||
fmt.Println()
|
||||
fmt.Println("If on the other hand this is a real deployment, please go into psql and manually")
|
||||
fmt.Println("create the user:")
|
||||
fmt.Println()
|
||||
fmt.Println(" CREATE USER <username> WITH")
|
||||
fmt.Println(" ENCRYPTED PASSWORD '<password>'")
|
||||
fmt.Println(" CREATEDB;")
|
||||
fmt.Println()
|
||||
fmt.Println("and add the username and password to your config.")
|
||||
os.Exit(1)
|
||||
}
|
||||
defer createUserConn.Close(ctx)
|
||||
|
||||
// Create the HMN user
|
||||
{
|
||||
userExists := workingCred.User == config.Config.Postgres.User && workingCred.Password == config.Config.Postgres.Password
|
||||
if !userExists {
|
||||
result := createUserConn.ExecParams(ctx, fmt.Sprintf(`
|
||||
CREATE USER %s WITH
|
||||
ENCRYPTED PASSWORD '%s'
|
||||
CREATEDB
|
||||
`, config.Config.Postgres.User, config.Config.Postgres.Password), nil, nil, nil, nil)
|
||||
_, err := result.Close()
|
||||
// Applies a cloned db to the local db.
|
||||
// Applies the seed after the migration specified in `afterMigration`.
|
||||
// NOTE(asaf): The db role specified in the config must have the CREATEDB attribute! `ALTER ROLE hmn WITH CREATEDB;`
|
||||
func SeedFromFile(seedFile string) {
|
||||
file, err := os.Open(seedFile)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to create HMN user: %w", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
panic(fmt.Errorf("couldn't open seed file %s: %w", seedFile, err))
|
||||
}
|
||||
file.Close()
|
||||
|
||||
// Connect as the HMN user
|
||||
conn, err := connectLowLevel(ctx, config.Config.Postgres.User, config.Config.Postgres.Password)
|
||||
fmt.Println("Resetting database...")
|
||||
{
|
||||
ctx := context.Background()
|
||||
// NOTE(asaf): We connect to db "template1", because we have to connect to something other than our own db in order to drop it.
|
||||
template1DSN := fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s",
|
||||
config.Config.Postgres.User,
|
||||
config.Config.Postgres.Password,
|
||||
config.Config.Postgres.Hostname,
|
||||
config.Config.Postgres.Port,
|
||||
"template1", // NOTE(asaf): template1 must always exist in postgres, as it's the db that gets cloned when you create new DBs
|
||||
)
|
||||
// NOTE(asaf): We have to use the low-level API of pgconn, because the pgx Exec always wraps the query in a transaction.
|
||||
lowLevelConn, err := pgconn.Connect(ctx, template1DSN)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to connect to db: %w", err))
|
||||
}
|
||||
defer lowLevelConn.Close(ctx)
|
||||
|
||||
// Disconnect all other users
|
||||
{
|
||||
result := conn.ExecParams(ctx, fmt.Sprintf(`
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname IN ('%s', 'template1') AND pid <> pg_backend_pid()
|
||||
`, config.Config.Postgres.DbName), nil, nil, nil, nil)
|
||||
_, err := result.Close()
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to disconnect other users: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the database
|
||||
{
|
||||
result := conn.ExecParams(ctx, fmt.Sprintf("DROP DATABASE %s", config.Config.Postgres.DbName), nil, nil, nil, nil)
|
||||
_, err := result.Close()
|
||||
result := lowLevelConn.ExecParams(ctx, fmt.Sprintf("DROP DATABASE %s", config.Config.Postgres.DbName), nil, nil, nil, nil)
|
||||
_, err = result.Close()
|
||||
pgErr, isPgError := err.(*pgconn.PgError)
|
||||
if err != nil {
|
||||
if !(isPgError && pgErr.SQLState() == "3D000") { // NOTE(asaf): 3D000 means "Database does not exist"
|
||||
panic(fmt.Errorf("failed to drop db: %w", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the database again
|
||||
{
|
||||
result := conn.ExecParams(ctx, fmt.Sprintf("CREATE DATABASE %s", config.Config.Postgres.DbName), nil, nil, nil, nil)
|
||||
_, err := result.Close()
|
||||
result = lowLevelConn.ExecParams(ctx, fmt.Sprintf("CREATE DATABASE %s", config.Config.Postgres.DbName), nil, nil, nil, nil)
|
||||
_, err = result.Close()
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to create db: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Database reset successfully.")
|
||||
}
|
||||
|
||||
func connectLowLevel(ctx context.Context, username, password string) (*pgconn.PgConn, error) {
|
||||
// NOTE(asaf): We connect to db "template1", because we have to connect to something other than our own db in order to drop it.
|
||||
template1DSN := fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s",
|
||||
username,
|
||||
password,
|
||||
config.Config.Postgres.Hostname,
|
||||
config.Config.Postgres.Port,
|
||||
"template1", // NOTE(asaf): template1 must always exist in postgres, as it's the db that gets cloned when you create new DBs
|
||||
fmt.Println("Executing seed...")
|
||||
cmd := exec.Command("pg_restore",
|
||||
"--single-transaction",
|
||||
"--dbname", config.Config.Postgres.DSN(),
|
||||
seedFile,
|
||||
)
|
||||
return pgconn.Connect(ctx, template1DSN)
|
||||
fmt.Println("Running command:", cmd)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
fmt.Print(string(output))
|
||||
panic(fmt.Errorf("failed to execute seed: %w", err))
|
||||
}
|
||||
|
||||
func getSystemUsername() string {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return ""
|
||||
fmt.Println("Done! You may want to migrate forward from here.")
|
||||
ListMigrations()
|
||||
}
|
||||
return u.Username
|
||||
|
||||
// NOTE(asaf): This will be useful for open-sourcing the website, but is not yet necessary.
|
||||
// Creates only what's necessary for a fresh deployment with no data
|
||||
// TODO(opensource)
|
||||
func BareMinimumSeed() {
|
||||
}
|
||||
|
||||
// NOTE(asaf): This will be useful for open-sourcing the website, but is not yet necessary.
|
||||
// Creates enough data for development
|
||||
// TODO(opensource)
|
||||
func SampleSeed() {
|
||||
// admin := CreateAdminUser("admin", "12345678")
|
||||
// user := CreateUser("regular_user", "12345678")
|
||||
// hmnProject := CreateProject("hmn", "Handmade Network")
|
||||
// Create category
|
||||
// Create thread
|
||||
// Create accepted user project
|
||||
// Create pending user project
|
||||
// Create showcase items
|
||||
// Create codelanguages
|
||||
// Create library and library resources
|
||||
}
|
||||
|
|
|
@ -110,7 +110,7 @@ func (m PersonalProjects) Up(ctx context.Context, tx pgx.Tx) error {
|
|||
// Port "jam snippets" to use a tag
|
||||
//
|
||||
|
||||
jamTagId, err := db.QueryOneScalar[int](ctx, tx, `INSERT INTO tags (text) VALUES ('wheeljam') RETURNING id`)
|
||||
jamTagId, err := db.QueryInt(ctx, tx, `INSERT INTO tags (text) VALUES ('wheeljam') RETURNING id`)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to create jam tag")
|
||||
}
|
||||
|
|
|
@ -1,146 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"github.com/jackc/pgx/v4"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerMigration(RenameEverything{})
|
||||
}
|
||||
|
||||
type RenameEverything struct{}
|
||||
|
||||
func (m RenameEverything) Version() types.MigrationVersion {
|
||||
return types.MigrationVersion(time.Date(2022, 5, 6, 22, 17, 26, 0, time.UTC))
|
||||
}
|
||||
|
||||
func (m RenameEverything) Name() string {
|
||||
return "RenameEverything"
|
||||
}
|
||||
|
||||
func (m RenameEverything) Description() string {
|
||||
return "Rename all the tables, and remove the ones we no longer need"
|
||||
}
|
||||
|
||||
func (m RenameEverything) Up(ctx context.Context, tx pgx.Tx) error {
|
||||
// Drop unused tables
|
||||
_, err := tx.Exec(ctx, `
|
||||
DROP TABLE
|
||||
auth_permission,
|
||||
django_content_type,
|
||||
django_migrations,
|
||||
django_site,
|
||||
handmade_blacklistemail,
|
||||
handmade_blacklisthostname,
|
||||
handmade_codelanguage,
|
||||
handmade_communicationchoice,
|
||||
handmade_communicationchoicelist,
|
||||
handmade_communicationsubcategory,
|
||||
handmade_communicationsubthread,
|
||||
handmade_kunenathread,
|
||||
handmade_license,
|
||||
handmade_license_texts,
|
||||
handmade_project_languages,
|
||||
handmade_project_licenses
|
||||
`)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to drop unused tables")
|
||||
}
|
||||
|
||||
// Rename everything!!
|
||||
_, err = tx.Exec(ctx, `
|
||||
ALTER TABLE auth_user RENAME TO hmn_user;
|
||||
ALTER TABLE discord_outgoingmessages RENAME TO discord_outgoing_message;
|
||||
ALTER TABLE handmade_asset RENAME TO asset;
|
||||
ALTER TABLE handmade_discordmessage RENAME TO discord_message;
|
||||
ALTER TABLE handmade_discordmessageattachment RENAME TO discord_message_attachment;
|
||||
ALTER TABLE handmade_discordmessagecontent RENAME TO discord_message_content;
|
||||
ALTER TABLE handmade_discordmessageembed RENAME TO discord_message_embed;
|
||||
ALTER TABLE handmade_discorduser RENAME TO discord_user;
|
||||
ALTER TABLE handmade_imagefile RENAME TO image_file;
|
||||
ALTER TABLE handmade_librarymediatype RENAME TO library_media_type;
|
||||
ALTER TABLE handmade_libraryresource RENAME TO library_resource;
|
||||
ALTER TABLE handmade_libraryresource_media_types RENAME TO library_resource_media_type;
|
||||
ALTER TABLE handmade_libraryresource_topics RENAME TO library_resource_topic;
|
||||
ALTER TABLE handmade_libraryresourcestar RENAME TO library_resource_star;
|
||||
ALTER TABLE handmade_librarytopic RENAME TO library_topic;
|
||||
ALTER TABLE handmade_links RENAME TO link;
|
||||
ALTER TABLE handmade_onetimetoken RENAME TO one_time_token;
|
||||
ALTER TABLE handmade_otherfile RENAME TO other_file;
|
||||
ALTER TABLE handmade_podcast RENAME TO podcast;
|
||||
ALTER TABLE handmade_podcastepisode RENAME TO podcast_episode;
|
||||
ALTER TABLE handmade_post RENAME TO post;
|
||||
ALTER TABLE handmade_post_asset_usage RENAME TO post_asset_usage;
|
||||
ALTER TABLE handmade_postversion RENAME TO post_version;
|
||||
ALTER TABLE handmade_project RENAME TO project;
|
||||
ALTER TABLE handmade_project_downloads RENAME TO project_download;
|
||||
ALTER TABLE handmade_project_screenshots RENAME TO project_screenshot;
|
||||
ALTER TABLE handmade_snippet RENAME TO snippet;
|
||||
ALTER TABLE handmade_subforum RENAME TO subforum;
|
||||
ALTER TABLE handmade_subforumlastreadinfo RENAME TO subforum_last_read_info;
|
||||
ALTER TABLE handmade_thread RENAME TO thread;
|
||||
ALTER TABLE handmade_threadlastreadinfo RENAME TO thread_last_read_info;
|
||||
ALTER TABLE handmade_user_projects RENAME TO user_project;
|
||||
ALTER TABLE sessions RENAME TO session;
|
||||
ALTER TABLE snippet_tags RENAME TO snippet_tag;
|
||||
ALTER TABLE tags RENAME TO tag;
|
||||
ALTER TABLE twitch_streams RENAME TO twitch_stream;
|
||||
|
||||
ALTER SEQUENCE auth_user_id_seq RENAME TO hmn_user_id_seq;
|
||||
ALTER SEQUENCE discord_outgoingmessages_id_seq RENAME TO discord_outgoing_message_id_seq;
|
||||
ALTER SEQUENCE handmade_category_id_seq RENAME TO subforum_id_seq;
|
||||
ALTER SEQUENCE handmade_categorylastreadinfo_id_seq RENAME TO subforum_last_read_info_id_seq;
|
||||
ALTER SEQUENCE handmade_discord_id_seq RENAME TO discord_user_id_seq;
|
||||
ALTER SEQUENCE handmade_discordmessageembed_id_seq RENAME TO discord_message_embed_id_seq;
|
||||
ALTER SEQUENCE handmade_imagefile_id_seq RENAME TO image_file_id_seq;
|
||||
ALTER SEQUENCE handmade_librarymediatype_id_seq RENAME TO library_media_type_id_seq;
|
||||
ALTER SEQUENCE handmade_libraryresource_id_seq RENAME TO library_resource_id_seq;
|
||||
ALTER SEQUENCE handmade_libraryresource_media_types_id_seq RENAME TO library_resource_media_type_id_seq;
|
||||
ALTER SEQUENCE handmade_libraryresource_topics_id_seq RENAME TO library_resource_topic_id_seq;
|
||||
ALTER SEQUENCE handmade_libraryresourcestar_id_seq RENAME TO library_resource_star_id_seq;
|
||||
ALTER SEQUENCE handmade_librarytopic_id_seq RENAME TO library_topic_id_seq;
|
||||
ALTER SEQUENCE handmade_links_id_seq RENAME TO link_id_seq;
|
||||
ALTER SEQUENCE handmade_onetimetoken_id_seq RENAME TO one_time_token_id_seq;
|
||||
ALTER SEQUENCE handmade_otherfile_id_seq RENAME TO other_file_id_seq;
|
||||
ALTER SEQUENCE handmade_podcast_id_seq RENAME TO podcast_id_seq;
|
||||
ALTER SEQUENCE handmade_post_id_seq RENAME TO post_id_seq;
|
||||
ALTER SEQUENCE handmade_postversion_id_seq RENAME TO post_version_id_seq;
|
||||
ALTER SEQUENCE handmade_project_downloads_id_seq RENAME TO project_download_id_seq;
|
||||
ALTER SEQUENCE handmade_project_id_seq RENAME TO project_id_seq;
|
||||
ALTER SEQUENCE handmade_project_screenshots_id_seq RENAME TO project_screenshot_id_seq;
|
||||
ALTER SEQUENCE handmade_snippet_id_seq RENAME TO snippet_id_seq;
|
||||
ALTER SEQUENCE handmade_thread_id_seq RENAME TO thread_id_seq;
|
||||
ALTER SEQUENCE handmade_threadlastreadinfo_id_seq RENAME TO thread_last_read_info_id_seq;
|
||||
ALTER SEQUENCE tags_id_seq RENAME TO tag_id_seq;
|
||||
|
||||
CREATE OR REPLACE FUNCTION thread_type_for_post(int) RETURNS int AS $$
|
||||
SELECT thread.type
|
||||
FROM
|
||||
public.post
|
||||
JOIN public.thread ON post.thread_id = thread.id
|
||||
WHERE post.id = $1
|
||||
$$ LANGUAGE SQL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION project_id_for_post(int) RETURNS int AS $$
|
||||
SELECT thread.project_id
|
||||
FROM
|
||||
public.post
|
||||
JOIN public.thread ON post.thread_id = thread.id
|
||||
WHERE post.id = $1
|
||||
$$ LANGUAGE SQL;
|
||||
`)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to rename tables")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m RenameEverything) Down(ctx context.Context, tx pgx.Tx) error {
|
||||
panic("Implement me")
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"github.com/jackc/pgx/v4"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerMigration(AddPersistentVars{})
|
||||
}
|
||||
|
||||
type AddPersistentVars struct{}
|
||||
|
||||
func (m AddPersistentVars) Version() types.MigrationVersion {
|
||||
return types.MigrationVersion(time.Date(2022, 5, 26, 14, 45, 17, 0, time.UTC))
|
||||
}
|
||||
|
||||
func (m AddPersistentVars) Name() string {
|
||||
return "AddPersistentVars"
|
||||
}
|
||||
|
||||
func (m AddPersistentVars) Description() string {
|
||||
return "Create table for persistent_vars"
|
||||
}
|
||||
|
||||
func (m AddPersistentVars) Up(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
CREATE TABLE persistent_var (
|
||||
name VARCHAR(255) NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX persistent_var_name ON persistent_var (name);
|
||||
`,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to create persistent_var table")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m AddPersistentVars) Down(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
DROP INDEX persistent_var_name;
|
||||
DROP TABLE persistent_var;
|
||||
`,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to drop persistent_var table")
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -1,395 +0,0 @@
|
|||
package migration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/auth"
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/hmndata"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/parsing"
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
lorem "github.com/HandmadeNetwork/golorem"
|
||||
"github.com/jackc/pgx/v4"
|
||||
)
|
||||
|
||||
// Applies a cloned db to the local db.
|
||||
// Applies the seed after the migration specified in `afterMigration`.
|
||||
// NOTE(asaf): The db role specified in the config must have the CREATEDB attribute! `ALTER ROLE hmn WITH CREATEDB;`
|
||||
func SeedFromFile(seedFile string) {
|
||||
file, err := os.Open(seedFile)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("couldn't open seed file %s: %w", seedFile, err))
|
||||
}
|
||||
file.Close()
|
||||
|
||||
fmt.Println("Executing seed...")
|
||||
cmd := exec.Command("pg_restore",
|
||||
"--single-transaction",
|
||||
"--dbname", config.Config.Postgres.DSN(),
|
||||
seedFile,
|
||||
)
|
||||
fmt.Println("Running command:", cmd)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
fmt.Print(string(output))
|
||||
panic(fmt.Errorf("failed to execute seed: %w", err))
|
||||
}
|
||||
|
||||
fmt.Println("Done! You may want to migrate forward from here.")
|
||||
ListMigrations()
|
||||
}
|
||||
|
||||
// Creates only what's necessary to get the site running. Not really very useful for
|
||||
// local dev on its own; sample data makes things a lot better.
|
||||
func BareMinimumSeed() *models.Project {
|
||||
Migrate(LatestVersion())
|
||||
|
||||
ctx := context.Background()
|
||||
conn := db.NewConnWithConfig(config.PostgresConfig{
|
||||
LogLevel: pgx.LogLevelWarn,
|
||||
})
|
||||
defer conn.Close(ctx)
|
||||
|
||||
tx := utils.Must1(conn.Begin(ctx))
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
fmt.Println("Creating HMN project...")
|
||||
hmn := seedProject(ctx, tx, seedHMN, nil)
|
||||
|
||||
utils.Must0(tx.Commit(ctx))
|
||||
|
||||
return hmn
|
||||
}
|
||||
|
||||
// Seeds the database with sample data for local dev.
|
||||
func SampleSeed() {
|
||||
hmn := BareMinimumSeed()
|
||||
|
||||
ctx := context.Background()
|
||||
conn := db.NewConnWithConfig(config.PostgresConfig{
|
||||
LogLevel: pgx.LogLevelWarn,
|
||||
})
|
||||
defer conn.Close(ctx)
|
||||
|
||||
tx := utils.Must1(conn.Begin(ctx))
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
fmt.Println("Creating admin user (\"admin\"/\"password\")...")
|
||||
admin := seedUser(ctx, tx, models.User{Username: "admin", Name: "Admin", Email: "admin@handmade.network", IsStaff: true})
|
||||
|
||||
fmt.Println("Creating normal users (all with password \"password\")...")
|
||||
alice := seedUser(ctx, tx, models.User{Username: "alice", Name: "Alice"})
|
||||
bob := seedUser(ctx, tx, models.User{Username: "bob", Name: "Bob"})
|
||||
charlie := seedUser(ctx, tx, models.User{Username: "charlie", Name: "Charlie"})
|
||||
|
||||
fmt.Println("Creating a spammer...")
|
||||
spammer := seedUser(ctx, tx, models.User{
|
||||
Username: "spam",
|
||||
Status: models.UserStatusConfirmed,
|
||||
Name: "Hot singletons in your local area",
|
||||
Bio: "Howdy, everybody I go by Jarva seesharpe from Bangalore. In this way, assuming you need to partake in a shared global instance with me then, at that poi",
|
||||
})
|
||||
|
||||
users := []*models.User{alice, bob, charlie, spammer}
|
||||
|
||||
fmt.Println("Creating starter projects...")
|
||||
hero := seedProject(ctx, tx, seedHandmadeHero, []*models.User{admin})
|
||||
fourcoder := seedProject(ctx, tx, seed4coder, []*models.User{bob})
|
||||
for i := 0; i < 5; i++ {
|
||||
name := fmt.Sprintf("%s %s", lorem.Word(1, 10), lorem.Word(1, 10))
|
||||
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
||||
|
||||
possibleOwners := []*models.User{alice, bob, charlie}
|
||||
var owners []*models.User
|
||||
for ownerIdx, owner := range possibleOwners {
|
||||
mask := (i % ((1 << len(possibleOwners)) - 1)) + 1
|
||||
if (1<<ownerIdx)&mask != 0 {
|
||||
owners = append(owners, owner)
|
||||
}
|
||||
}
|
||||
|
||||
seedProject(ctx, tx, models.Project{
|
||||
Slug: slug,
|
||||
Name: name,
|
||||
Blurb: lorem.Sentence(6, 16),
|
||||
Description: lorem.Paragraph(3, 5),
|
||||
|
||||
Personal: true,
|
||||
}, owners)
|
||||
}
|
||||
// spam project!
|
||||
seedProject(ctx, tx, models.Project{
|
||||
Slug: "spam",
|
||||
Name: "Cheap abstraction enhancers",
|
||||
Blurb: "Get higher than ever before...up the ladder of abstraction.",
|
||||
Description: "Tired of boring details like the actual problem assigned to you? The sky's the limit with these abstraction enhancers, guaranteed to sweep away all those pesky details so you can focus on what matters: \"architecture\".",
|
||||
|
||||
Personal: true,
|
||||
}, []*models.User{spammer})
|
||||
|
||||
fmt.Println("Creating some forum threads...")
|
||||
for i := 0; i < 5; i++ {
|
||||
for _, project := range []*models.Project{hmn, hero, fourcoder} {
|
||||
thread := seedThread(ctx, tx, project, models.Thread{})
|
||||
populateThread(ctx, tx, thread, users, rand.Intn(5)+1)
|
||||
}
|
||||
}
|
||||
// spam-only thread
|
||||
{
|
||||
thread := seedThread(ctx, tx, hmn, models.Thread{})
|
||||
populateThread(ctx, tx, thread, []*models.User{spammer}, 1)
|
||||
}
|
||||
|
||||
fmt.Println("Creating news posts...")
|
||||
{
|
||||
// Main site news posts
|
||||
for i := 0; i < 3; i++ {
|
||||
thread := seedThread(ctx, tx, hmn, models.Thread{Type: models.ThreadTypeProjectBlogPost})
|
||||
populateThread(ctx, tx, thread, []*models.User{admin, alice, bob, charlie}, rand.Intn(5)+1)
|
||||
}
|
||||
|
||||
// 4coder
|
||||
for i := 0; i < 5; i++ {
|
||||
thread := seedThread(ctx, tx, fourcoder, models.Thread{Type: models.ThreadTypeProjectBlogPost})
|
||||
populateThread(ctx, tx, thread, []*models.User{bob}, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// admin := CreateAdminUser("admin", "12345678")
|
||||
// user := CreateUser("regular_user", "12345678")
|
||||
// hmnProject := CreateProject("hmn", "Handmade Network")
|
||||
// Create category
|
||||
// Create thread
|
||||
// Create accepted user project
|
||||
// Create pending user project
|
||||
// Create showcase items
|
||||
// Create codelanguages
|
||||
// Create library and library resources
|
||||
|
||||
utils.Must0(tx.Commit(ctx))
|
||||
}
|
||||
|
||||
func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models.User {
|
||||
user := db.MustQueryOne[models.User](ctx, conn,
|
||||
`
|
||||
INSERT INTO hmn_user (
|
||||
username, password, email,
|
||||
is_staff,
|
||||
status,
|
||||
name, bio, blurb, signature,
|
||||
darktheme,
|
||||
showemail, edit_library,
|
||||
date_joined, registration_ip, avatar_asset_id
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3,
|
||||
$4,
|
||||
$5,
|
||||
$6, $7, $8, $9,
|
||||
TRUE,
|
||||
$10, FALSE,
|
||||
'2017-01-01T00:00:00Z', '192.168.2.1', null
|
||||
)
|
||||
RETURNING $columns
|
||||
`,
|
||||
input.Username, "", utils.OrDefault(input.Email, fmt.Sprintf("%s@example.com", input.Username)),
|
||||
input.IsStaff,
|
||||
utils.OrDefault(input.Status, models.UserStatusApproved),
|
||||
utils.OrDefault(input.Name, randomName()), utils.OrDefault(input.Bio, lorem.Paragraph(0, 2)), utils.OrDefault(input.Blurb, lorem.Sentence(0, 14)), utils.OrDefault(input.Signature, lorem.Sentence(0, 16)),
|
||||
input.ShowEmail,
|
||||
)
|
||||
utils.Must0(auth.SetPassword(ctx, conn, input.Username, "password"))
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func seedThread(ctx context.Context, tx pgx.Tx, project *models.Project, input models.Thread) *models.Thread {
|
||||
input.Type = utils.OrDefault(input.Type, models.ThreadTypeForumPost)
|
||||
|
||||
var defaultSubforum *int
|
||||
if input.Type == models.ThreadTypeForumPost {
|
||||
defaultSubforum = project.ForumID
|
||||
}
|
||||
|
||||
thread, err := db.QueryOne[models.Thread](ctx, tx,
|
||||
`
|
||||
INSERT INTO thread (
|
||||
title,
|
||||
type, sticky,
|
||||
project_id, subforum_id,
|
||||
first_id, last_id
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2, $3,
|
||||
$4, $5,
|
||||
$6, $7
|
||||
)
|
||||
RETURNING $columns
|
||||
`,
|
||||
utils.OrDefault(input.Title, lorem.Sentence(3, 8)),
|
||||
utils.OrDefault(input.Type, models.ThreadTypeForumPost), false,
|
||||
project.ID, utils.OrDefault(input.SubforumID, defaultSubforum),
|
||||
-1, -1,
|
||||
)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to create thread"))
|
||||
}
|
||||
|
||||
return thread
|
||||
}
|
||||
|
||||
func populateThread(ctx context.Context, tx pgx.Tx, thread *models.Thread, users []*models.User, numPosts int) {
|
||||
var lastPostId int
|
||||
for i := 0; i < numPosts; i++ {
|
||||
user := users[i%len(users)]
|
||||
|
||||
var replyId *int
|
||||
if lastPostId != 0 {
|
||||
if rand.Intn(10) < 3 {
|
||||
replyId = &lastPostId
|
||||
}
|
||||
}
|
||||
|
||||
hmndata.CreateNewPost(ctx, tx, thread.ProjectID, thread.ID, thread.Type, user.ID, replyId, lorem.Paragraph(1, 10), "192.168.2.1")
|
||||
}
|
||||
}
|
||||
|
||||
var latestProjectId int
|
||||
|
||||
func seedProject(ctx context.Context, tx pgx.Tx, input models.Project, owners []*models.User) *models.Project {
|
||||
project := db.MustQueryOne[models.Project](ctx, tx,
|
||||
`
|
||||
INSERT INTO project (
|
||||
id,
|
||||
slug, name, blurb,
|
||||
description, descparsed,
|
||||
color_1, color_2,
|
||||
featured, personal, lifecycle, hidden,
|
||||
forum_enabled, blog_enabled,
|
||||
date_created
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2, $3, $4,
|
||||
$5, $6,
|
||||
$7, $8,
|
||||
$9, $10, $11, $12,
|
||||
$13, $14,
|
||||
$15
|
||||
)
|
||||
RETURNING $columns
|
||||
`,
|
||||
utils.OrDefault(input.ID, latestProjectId+1),
|
||||
input.Slug, input.Name, input.Blurb,
|
||||
input.Description, parsing.ParseMarkdown(input.Description, parsing.ForumRealMarkdown),
|
||||
input.Color1, input.Color2,
|
||||
input.Featured, input.Personal, utils.OrDefault(input.Lifecycle, models.ProjectLifecycleActive), input.Hidden,
|
||||
input.ForumEnabled, input.BlogEnabled,
|
||||
utils.OrDefault(input.DateCreated, time.Now()),
|
||||
)
|
||||
latestProjectId = utils.IntMax(latestProjectId, project.ID)
|
||||
|
||||
// Create forum (even if unused)
|
||||
forum := db.MustQueryOne[models.Subforum](ctx, tx,
|
||||
`
|
||||
INSERT INTO subforum (
|
||||
slug, name,
|
||||
project_id
|
||||
)
|
||||
VALUES (
|
||||
$1, $2,
|
||||
$3
|
||||
)
|
||||
RETURNING $columns
|
||||
`,
|
||||
"", project.Name,
|
||||
project.ID,
|
||||
)
|
||||
|
||||
// Associate forum with project
|
||||
utils.Must1(tx.Exec(ctx,
|
||||
`UPDATE project SET forum_id = $1 WHERE id = $2`,
|
||||
forum.ID, project.ID,
|
||||
))
|
||||
project.ForumID = &forum.ID
|
||||
|
||||
// Add project owners
|
||||
for _, owner := range owners {
|
||||
utils.Must1(tx.Exec(ctx,
|
||||
`INSERT INTO user_project (user_id, project_id) VALUES ($1, $2)`,
|
||||
owner.ID, project.ID,
|
||||
))
|
||||
}
|
||||
|
||||
return project
|
||||
}
|
||||
|
||||
func randomName() string {
|
||||
return "John Doe" // chosen by fair dice roll. guaranteed to be random.
|
||||
}
|
||||
|
||||
func randomBool() bool {
|
||||
return rand.Intn(2) == 1
|
||||
}
|
||||
|
||||
var seedHMN = models.Project{
|
||||
ID: models.HMNProjectID,
|
||||
Slug: models.HMNProjectSlug,
|
||||
Name: "Handmade Network",
|
||||
Blurb: "Changing the way software is written",
|
||||
Description: `
|
||||
[project=hero]Originally inspired by Handmade Hero[/project], we're an offshoot of its community, hoping to change the way software is written. To this end we've circulated our [url=https://handmade.network/manifesto]manifesto[/url] and built this website, in the hopes of fostering this community. We invite others to host projects built with same goals in mind and build up or expand their community's reach in our little tree house and hope it proves vibrant soil for the exchange of ideas as well as code.
|
||||
`,
|
||||
|
||||
Color1: "ab4c47", Color2: "a5467d",
|
||||
Hidden: true,
|
||||
ForumEnabled: true, BlogEnabled: true,
|
||||
|
||||
DateCreated: time.Date(2017, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
var seedHandmadeHero = models.Project{
|
||||
Slug: "hero",
|
||||
Name: "Handmade Hero",
|
||||
Blurb: "An ongoing project to create a complete, professional-quality game accompanied by videos that explain every single line of its source code.",
|
||||
Description: `
|
||||
Handmade Hero is an ongoing project by [Casey Muratori](http://mollyrocket.com/casey) to create a complete, professional-quality game accompanied by videos that explain every single line of its source code. The series began on November 17th, 2014, and is estimated to run for at least 600 episodes. Programming sessions are limited to one hour per weekday so it remains manageable for people who practice coding along with the series at home.
|
||||
|
||||
For more information, see the official website at https://handmadehero.org
|
||||
`,
|
||||
|
||||
Color1: "19328a", Color2: "f1f0a2",
|
||||
Featured: true,
|
||||
ForumEnabled: true, BlogEnabled: false,
|
||||
|
||||
DateCreated: time.Date(2017, 1, 10, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
var seed4coder = models.Project{
|
||||
Slug: "4coder",
|
||||
Name: "4coder",
|
||||
Blurb: "A programmable, cross platform, IDE template",
|
||||
Description: `
|
||||
4coder preview video: https://www.youtube.com/watch?v=Nop5UW2kV3I
|
||||
|
||||
4coder differentiates from other editors by focusing on powerful C/C++ customization and extension, and ease of cross platform use. This means that 4coder greatly reduces the cost of creating cross platform development tools such as debuggers, code intelligence systems. It means that tools specialized to your particular needs can be programmed in C/C++ or any language that interfaces with C/C++, which is almost all of them.
|
||||
|
||||
In other words, 4coder is attempting to live in a space between an IDE and a power editor such as Emacs or Vim.
|
||||
|
||||
Want to try it out? [url=https://4coder.itch.io/4coder]Get your alpha build now[/url]!
|
||||
`,
|
||||
|
||||
Color1: "002107", Color2: "cccccc",
|
||||
ForumEnabled: true, BlogEnabled: true,
|
||||
|
||||
DateCreated: time.Date(2017, 1, 10, 0, 0, 0, 0, time.UTC),
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
Clean this up once we get the website working
|
||||
---------------------------------------------
|
||||
TODO: Questionable db tables that we inherited from Django:
|
||||
* auth_permission
|
||||
* auth_user_user_permissions
|
||||
* django_admin_log
|
||||
* django_content_type
|
||||
* django_migrations
|
||||
* django_session
|
||||
* django_site
|
||||
|
||||
TODO: Questionable db tables that we inherited from the old website:
|
||||
* handmade_blacklistemail
|
||||
* handmade_blacklisthostname
|
||||
* handmade_communicationchoice
|
||||
* handmade_communicationchoicelist
|
||||
* handmade_communicationsubcategory
|
||||
* handmade_communicationsubthread
|
||||
* handmade_kunenapost
|
||||
* handmade_license
|
||||
* handmade_license_texts
|
||||
* handmade_memberextended.joomlaid
|
||||
* handmade_onetimetoken // Is this for password resets??
|
||||
* handmade_otherfile
|
||||
* handmade_project_licenses
|
|
@ -1,6 +0,0 @@
|
|||
package models
|
||||
|
||||
type PersistentVar struct {
|
||||
Name string `db:"name"`
|
||||
Value string `db:"value"`
|
||||
}
|
|
@ -44,10 +44,14 @@ func (node *SubforumTreeNode) GetLineage() []*Subforum {
|
|||
}
|
||||
|
||||
func GetFullSubforumTree(ctx context.Context, conn *pgxpool.Pool) SubforumTree {
|
||||
subforums, err := db.Query[Subforum](ctx, conn,
|
||||
type subforumRow struct {
|
||||
Subforum Subforum `db:"sf"`
|
||||
}
|
||||
rowsSlice, err := db.Query(ctx, conn, subforumRow{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM subforum
|
||||
FROM
|
||||
handmade_subforum as sf
|
||||
ORDER BY sort, id ASC
|
||||
`,
|
||||
)
|
||||
|
@ -55,9 +59,10 @@ func GetFullSubforumTree(ctx context.Context, conn *pgxpool.Pool) SubforumTree {
|
|||
panic(oops.New(err, "failed to fetch subforum tree"))
|
||||
}
|
||||
|
||||
sfTreeMap := make(map[int]*SubforumTreeNode, len(subforums))
|
||||
for _, sf := range subforums {
|
||||
sfTreeMap[sf.ID] = &SubforumTreeNode{Subforum: *sf}
|
||||
sfTreeMap := make(map[int]*SubforumTreeNode, len(rowsSlice))
|
||||
for _, row := range rowsSlice {
|
||||
sf := row.(*subforumRow).Subforum
|
||||
sfTreeMap[sf.ID] = &SubforumTreeNode{Subforum: sf}
|
||||
}
|
||||
|
||||
for _, node := range sfTreeMap {
|
||||
|
@ -66,8 +71,9 @@ func GetFullSubforumTree(ctx context.Context, conn *pgxpool.Pool) SubforumTree {
|
|||
}
|
||||
}
|
||||
|
||||
for _, cat := range subforums {
|
||||
for _, row := range rowsSlice {
|
||||
// NOTE(asaf): Doing this in a separate loop over rowsSlice to ensure that Children are in db order.
|
||||
cat := row.(*subforumRow).Subforum
|
||||
node := sfTreeMap[cat.ID]
|
||||
if node.Parent != nil {
|
||||
node.Parent.Children = append(node.Parent.Children, node)
|
||||
|
|
|
@ -36,6 +36,7 @@ type User struct {
|
|||
Blurb string `db:"blurb"`
|
||||
Signature string `db:"signature"`
|
||||
AvatarAssetID *uuid.UUID `db:"avatar_asset_id"`
|
||||
AvatarAsset *Asset `db:"avatar"`
|
||||
|
||||
DarkTheme bool `db:"darktheme"`
|
||||
Timezone string `db:"timezone"`
|
||||
|
@ -47,9 +48,6 @@ type User struct {
|
|||
DiscordDeleteSnippetOnMessageDelete bool `db:"discord_delete_snippet_on_message_delete"`
|
||||
|
||||
MarkedAllReadAt time.Time `db:"marked_all_read_at"`
|
||||
|
||||
// Non-db fields, to be filled in by fetch helpers
|
||||
AvatarAsset *Asset
|
||||
}
|
||||
|
||||
func (u *User) BestName() string {
|
||||
|
|
|
@ -5,7 +5,6 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/jobs"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
|
@ -110,20 +109,20 @@ type PerfStorage struct {
|
|||
|
||||
type PerfCollector struct {
|
||||
In chan<- RequestPerf
|
||||
Job jobs.Job
|
||||
Done <-chan struct{}
|
||||
RequestCopy chan<- (chan<- PerfStorage)
|
||||
}
|
||||
|
||||
func RunPerfCollector(ctx context.Context) *PerfCollector {
|
||||
in := make(chan RequestPerf)
|
||||
job := jobs.New()
|
||||
done := make(chan struct{})
|
||||
requestCopy := make(chan (chan<- PerfStorage))
|
||||
|
||||
var storage PerfStorage
|
||||
// TODO(asaf): Load history from file
|
||||
|
||||
go func() {
|
||||
defer job.Done()
|
||||
defer close(done)
|
||||
|
||||
for {
|
||||
select {
|
||||
|
@ -140,7 +139,7 @@ func RunPerfCollector(ctx context.Context) *PerfCollector {
|
|||
|
||||
perfCollector := PerfCollector{
|
||||
In: in,
|
||||
Job: job,
|
||||
Done: done,
|
||||
RequestCopy: requestCopy,
|
||||
}
|
||||
return &perfCollector
|
||||
|
|
|
@ -181,12 +181,10 @@
|
|||
sizes using CSS grid properties.
|
||||
*/}}
|
||||
|
||||
{{ with .NewsPost }}
|
||||
<div>
|
||||
<h2>Latest News</h2>
|
||||
{{ template "timeline_item.html" . }}
|
||||
{{ template "timeline_item.html" .NewsPost }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="landing-right">
|
||||
<h2>Around the Network</h2>
|
||||
<div class="optionbar mb2">
|
||||
|
|
|
@ -3,13 +3,13 @@ package twitch
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/discord"
|
||||
"git.handmade.network/hmn/hmn/src/hmndata"
|
||||
"git.handmade.network/hmn/hmn/src/jobs"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
|
@ -25,23 +25,25 @@ type twitchNotification struct {
|
|||
var twitchNotificationChannel chan twitchNotification
|
||||
var linksChangedChannel chan struct{}
|
||||
|
||||
func MonitorTwitchSubscriptions(ctx context.Context, dbConn *pgxpool.Pool) jobs.Job {
|
||||
func MonitorTwitchSubscriptions(ctx context.Context, dbConn *pgxpool.Pool) <-chan struct{} {
|
||||
log := logging.ExtractLogger(ctx).With().Str("twitch goroutine", "stream monitor").Logger()
|
||||
ctx = logging.AttachLoggerToContext(&log, ctx)
|
||||
|
||||
if config.Config.Twitch.ClientID == "" {
|
||||
log.Warn().Msg("No twitch config provided.")
|
||||
return jobs.Noop()
|
||||
done := make(chan struct{}, 1)
|
||||
done <- struct{}{}
|
||||
return done
|
||||
}
|
||||
|
||||
twitchNotificationChannel = make(chan twitchNotification, 100)
|
||||
linksChangedChannel = make(chan struct{}, 10)
|
||||
job := jobs.New()
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
log.Info().Msg("Shutting down twitch monitor")
|
||||
job.Done()
|
||||
done <- struct{}{}
|
||||
}()
|
||||
log.Info().Msg("Running twitch monitor...")
|
||||
|
||||
|
@ -112,7 +114,7 @@ func MonitorTwitchSubscriptions(ctx context.Context, dbConn *pgxpool.Pool) jobs.
|
|||
}
|
||||
}()
|
||||
|
||||
return job
|
||||
return done
|
||||
}
|
||||
|
||||
type twitchNotificationType int
|
||||
|
@ -326,7 +328,7 @@ func syncWithTwitch(ctx context.Context, dbConn *pgxpool.Pool, updateAll bool) {
|
|||
}
|
||||
p.StartBlock("SQL", "Remove untracked streamers")
|
||||
_, err = tx.Exec(ctx,
|
||||
`DELETE FROM twitch_stream WHERE twitch_id != ANY($1)`,
|
||||
`DELETE FROM twitch_streams WHERE twitch_id != ANY($1)`,
|
||||
allIDs,
|
||||
)
|
||||
if err != nil {
|
||||
|
@ -361,7 +363,7 @@ func syncWithTwitch(ctx context.Context, dbConn *pgxpool.Pool, updateAll bool) {
|
|||
p.StartBlock("SQL", "Update stream statuses in db")
|
||||
for _, status := range statuses {
|
||||
log.Debug().Interface("Status", status).Msg("Got streamer")
|
||||
err = updateStreamStatusInDB(ctx, tx, &status)
|
||||
_, err = updateStreamStatusInDB(ctx, tx, &status)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to update twitch stream status")
|
||||
}
|
||||
|
@ -373,41 +375,19 @@ func syncWithTwitch(ctx context.Context, dbConn *pgxpool.Pool, updateAll bool) {
|
|||
}
|
||||
stats.NumStreamsChecked += len(usersToUpdate)
|
||||
log.Info().Interface("Stats", stats).Msg("Twitch sync done")
|
||||
|
||||
log.Debug().Msg("Notifying discord")
|
||||
err = notifyDiscordOfLiveStream(ctx, dbConn)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to notify discord")
|
||||
}
|
||||
}
|
||||
|
||||
func notifyDiscordOfLiveStream(ctx context.Context, dbConn db.ConnOrTx) error {
|
||||
streams, err := db.Query[models.TwitchStream](ctx, dbConn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
twitch_stream
|
||||
ORDER BY started_at ASC
|
||||
`,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to fetch livestreams from db")
|
||||
}
|
||||
|
||||
var streamDetails []hmndata.StreamDetails
|
||||
for _, s := range streams {
|
||||
streamDetails = append(streamDetails, hmndata.StreamDetails{
|
||||
Username: s.Login,
|
||||
StartTime: s.StartedAt,
|
||||
Title: s.Title,
|
||||
func notifyDiscordOfLiveStream(ctx context.Context, dbConn db.ConnOrTx, twitchLogin string, title string) error {
|
||||
var err error
|
||||
if config.Config.Discord.StreamsChannelID != "" {
|
||||
err = discord.SendMessages(ctx, dbConn, discord.MessageToSend{
|
||||
ChannelID: config.Config.Discord.StreamsChannelID,
|
||||
Req: discord.CreateMessageRequest{
|
||||
Content: fmt.Sprintf("%s is live: https://twitch.tv/%s\n> %s", twitchLogin, twitchLogin, title),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err = discord.UpdateStreamers(ctx, dbConn, streamDetails)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to update discord with livestream info")
|
||||
}
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
func processEventSubNotification(ctx context.Context, dbConn db.ConnOrTx, notification *twitchNotification) {
|
||||
|
@ -442,25 +422,41 @@ func processEventSubNotification(ctx context.Context, dbConn db.ConnOrTx, notifi
|
|||
}
|
||||
|
||||
log.Debug().Interface("Status", status).Msg("Updating status")
|
||||
err = updateStreamStatusInDB(ctx, dbConn, &status)
|
||||
inserted, err := updateStreamStatusInDB(ctx, dbConn, &status)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to update twitch stream status")
|
||||
}
|
||||
|
||||
if inserted {
|
||||
log.Debug().Msg("Notifying discord")
|
||||
err = notifyDiscordOfLiveStream(ctx, dbConn)
|
||||
err = notifyDiscordOfLiveStream(ctx, dbConn, status.TwitchLogin, status.Title)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to notify discord")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateStreamStatusInDB(ctx context.Context, conn db.ConnOrTx, status *streamStatus) error {
|
||||
func updateStreamStatusInDB(ctx context.Context, conn db.ConnOrTx, status *streamStatus) (bool, error) {
|
||||
log := logging.ExtractLogger(ctx)
|
||||
inserted := false
|
||||
if isStatusRelevant(status) {
|
||||
log.Debug().Msg("Status relevant")
|
||||
_, err := conn.Exec(ctx,
|
||||
_, err := db.QueryOne(ctx, conn, models.TwitchStream{},
|
||||
`
|
||||
INSERT INTO twitch_stream (twitch_id, twitch_login, title, started_at)
|
||||
SELECT $columns
|
||||
FROM twitch_streams
|
||||
WHERE twitch_id = $1
|
||||
`,
|
||||
status.TwitchID,
|
||||
)
|
||||
if err == db.NotFound {
|
||||
log.Debug().Msg("Inserting new stream")
|
||||
inserted = true
|
||||
} else if err != nil {
|
||||
return false, oops.New(err, "failed to query existing stream")
|
||||
}
|
||||
_, err = conn.Exec(ctx,
|
||||
`
|
||||
INSERT INTO twitch_streams (twitch_id, twitch_login, title, started_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (twitch_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
|
@ -472,21 +468,21 @@ func updateStreamStatusInDB(ctx context.Context, conn db.ConnOrTx, status *strea
|
|||
status.StartedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to insert twitch streamer into db")
|
||||
return false, oops.New(err, "failed to insert twitch streamer into db")
|
||||
}
|
||||
} else {
|
||||
log.Debug().Msg("Stream not relevant")
|
||||
_, err := conn.Exec(ctx,
|
||||
`
|
||||
DELETE FROM twitch_stream WHERE twitch_id = $1
|
||||
DELETE FROM twitch_streams WHERE twitch_id = $1
|
||||
`,
|
||||
status.TwitchID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to remove twitch streamer from db")
|
||||
return false, oops.New(err, "failed to remove twitch streamer from db")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return inserted, nil
|
||||
}
|
||||
|
||||
var RelevantCategories = []string{
|
||||
|
|
|
@ -10,33 +10,6 @@ import (
|
|||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
)
|
||||
|
||||
// Returns the provided value, or a default value if the input was zero.
|
||||
func OrDefault[T comparable](v T, def T) T {
|
||||
var zero T
|
||||
if v == zero {
|
||||
return def
|
||||
} else {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// Takes an (error) return and panics if there is an error.
|
||||
// Helps avoid `if err != nil` in scripts. Use sparingly in real code.
|
||||
func Must0(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Takes a (something, error) return and panics if there is an error.
|
||||
// Helps avoid `if err != nil` in scripts. Use sparingly in real code.
|
||||
func Must1[T any](v T, err error) T {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func IntMin(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
|
|
|
@ -207,11 +207,11 @@ func AdminApprovalQueue(c *RequestContext) ResponseData {
|
|||
userIds = append(userIds, u.User.ID)
|
||||
}
|
||||
|
||||
userLinks, err := db.Query[models.Link](c.Context(), c.Conn,
|
||||
userLinks, err := db.Query(c.Context(), c.Conn, models.Link{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
link
|
||||
handmade_links
|
||||
WHERE
|
||||
user_id = ANY($1)
|
||||
ORDER BY ordering ASC
|
||||
|
@ -222,7 +222,8 @@ func AdminApprovalQueue(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user links"))
|
||||
}
|
||||
|
||||
for _, link := range userLinks {
|
||||
for _, ul := range userLinks {
|
||||
link := ul.(*models.Link)
|
||||
userData := unapprovedUsers[userIDToDataIdx[*link.UserID]]
|
||||
userData.UserLinks = append(userData.UserLinks, templates.LinkToTemplate(link))
|
||||
}
|
||||
|
@ -256,7 +257,18 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
|
|||
return RejectRequest(c, "User id can't be parsed")
|
||||
}
|
||||
|
||||
user, err := hmndata.FetchUser(c.Context(), c.Conn, c.CurrentUser, userId, hmndata.UsersQuery{})
|
||||
type userQuery struct {
|
||||
User models.User `db:"auth_user"`
|
||||
}
|
||||
u, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM auth_user
|
||||
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
|
||||
WHERE auth_user.id = $1
|
||||
`,
|
||||
userId,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
return RejectRequest(c, "User not found")
|
||||
|
@ -264,12 +276,13 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user"))
|
||||
}
|
||||
}
|
||||
user := u.(*userQuery).User
|
||||
|
||||
whatHappened := ""
|
||||
if action == ApprovalQueueActionApprove {
|
||||
_, err := c.Conn.Exec(c.Context(),
|
||||
`
|
||||
UPDATE hmn_user
|
||||
UPDATE auth_user
|
||||
SET status = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
|
@ -283,7 +296,7 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
|
|||
} else if action == ApprovalQueueActionSpammer {
|
||||
_, err := c.Conn.Exec(c.Context(),
|
||||
`
|
||||
UPDATE hmn_user
|
||||
UPDATE auth_user
|
||||
SET status = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
|
@ -324,16 +337,16 @@ type UnapprovedPost struct {
|
|||
}
|
||||
|
||||
func fetchUnapprovedPosts(c *RequestContext) ([]*UnapprovedPost, error) {
|
||||
posts, err := db.Query[UnapprovedPost](c.Context(), c.Conn,
|
||||
it, err := db.Query(c.Context(), c.Conn, UnapprovedPost{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
post
|
||||
JOIN project ON post.project_id = project.id
|
||||
JOIN thread ON post.thread_id = thread.id
|
||||
JOIN post_version AS ver ON ver.id = post.current_id
|
||||
JOIN hmn_user AS author ON author.id = post.author_id
|
||||
LEFT JOIN asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
|
||||
handmade_post AS post
|
||||
JOIN handmade_project AS project ON post.project_id = project.id
|
||||
JOIN handmade_thread AS thread ON post.thread_id = thread.id
|
||||
JOIN handmade_postversion AS ver ON ver.id = post.current_id
|
||||
JOIN auth_user AS author ON author.id = post.author_id
|
||||
LEFT JOIN handmade_asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
|
||||
WHERE
|
||||
NOT thread.deleted
|
||||
AND NOT post.deleted
|
||||
|
@ -345,7 +358,11 @@ func fetchUnapprovedPosts(c *RequestContext) ([]*UnapprovedPost, error) {
|
|||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch unapproved posts")
|
||||
}
|
||||
return posts, nil
|
||||
var res []*UnapprovedPost
|
||||
for _, iresult := range it {
|
||||
res = append(res, iresult.(*UnapprovedPost))
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
type UnapprovedProject struct {
|
||||
|
@ -355,11 +372,14 @@ type UnapprovedProject struct {
|
|||
}
|
||||
|
||||
func fetchUnapprovedProjects(c *RequestContext) ([]UnapprovedProject, error) {
|
||||
ownerIDs, err := db.QueryScalar[int](c.Context(), c.Conn,
|
||||
type unapprovedUser struct {
|
||||
ID int `db:"id"`
|
||||
}
|
||||
it, err := db.Query(c.Context(), c.Conn, unapprovedUser{},
|
||||
`
|
||||
SELECT id
|
||||
SELECT $columns
|
||||
FROM
|
||||
hmn_user AS u
|
||||
auth_user AS u
|
||||
WHERE
|
||||
u.status = ANY($1)
|
||||
`,
|
||||
|
@ -368,6 +388,10 @@ func fetchUnapprovedProjects(c *RequestContext) ([]UnapprovedProject, error) {
|
|||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch unapproved users")
|
||||
}
|
||||
ownerIDs := make([]int, 0, len(it))
|
||||
for _, uid := range it {
|
||||
ownerIDs = append(ownerIDs, uid.(*unapprovedUser).ID)
|
||||
}
|
||||
|
||||
projects, err := hmndata.FetchProjects(c.Context(), c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
OwnerIDs: ownerIDs,
|
||||
|
@ -382,11 +406,11 @@ func fetchUnapprovedProjects(c *RequestContext) ([]UnapprovedProject, error) {
|
|||
projectIDs = append(projectIDs, p.Project.ID)
|
||||
}
|
||||
|
||||
projectLinks, err := db.Query[models.Link](c.Context(), c.Conn,
|
||||
projectLinks, err := db.Query(c.Context(), c.Conn, models.Link{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
link
|
||||
handmade_links AS link
|
||||
WHERE
|
||||
link.project_id = ANY($1)
|
||||
ORDER BY link.ordering ASC
|
||||
|
@ -401,7 +425,8 @@ func fetchUnapprovedProjects(c *RequestContext) ([]UnapprovedProject, error) {
|
|||
|
||||
for idx, proj := range projects {
|
||||
links := make([]*models.Link, 0, 10) // NOTE(asaf): 10 should be enough for most projects.
|
||||
for _, link := range projectLinks {
|
||||
for _, l := range projectLinks {
|
||||
link := l.(*models.Link)
|
||||
if *link.ProjectID == proj.Project.ID {
|
||||
links = append(links, link)
|
||||
}
|
||||
|
@ -430,13 +455,13 @@ func deleteAllPostsForUser(ctx context.Context, conn *pgxpool.Pool, userId int)
|
|||
ThreadID int `db:"thread.id"`
|
||||
PostID int `db:"post.id"`
|
||||
}
|
||||
rows, err := db.Query[toDelete](ctx, tx,
|
||||
it, err := db.Query(ctx, tx, toDelete{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
post as post
|
||||
JOIN thread ON post.thread_id = thread.id
|
||||
JOIN hmn_user AS author ON author.id = post.author_id
|
||||
handmade_post as post
|
||||
JOIN handmade_thread AS thread ON post.thread_id = thread.id
|
||||
JOIN auth_user AS author ON author.id = post.author_id
|
||||
WHERE author.id = $1
|
||||
`,
|
||||
userId,
|
||||
|
@ -446,7 +471,8 @@ func deleteAllPostsForUser(ctx context.Context, conn *pgxpool.Pool, userId int)
|
|||
return oops.New(err, "failed to fetch posts to delete for user")
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
for _, iResult := range it {
|
||||
row := iResult.(*toDelete)
|
||||
hmndata.DeletePost(ctx, tx, row.ThreadID, row.PostID)
|
||||
}
|
||||
err = tx.Commit(ctx)
|
||||
|
@ -463,12 +489,12 @@ func deleteAllProjectsForUser(ctx context.Context, conn *pgxpool.Pool, userId in
|
|||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
projectIDsToDelete, err := db.QueryScalar[int](ctx, tx,
|
||||
toDelete, err := db.Query(ctx, tx, models.Project{},
|
||||
`
|
||||
SELECT project.id
|
||||
SELECT $columns
|
||||
FROM
|
||||
project
|
||||
JOIN user_project AS up ON up.project_id = project.id
|
||||
handmade_project AS project
|
||||
JOIN handmade_user_projects AS up ON up.project_id = project.id
|
||||
WHERE
|
||||
up.user_id = $1
|
||||
`,
|
||||
|
@ -478,12 +504,17 @@ func deleteAllProjectsForUser(ctx context.Context, conn *pgxpool.Pool, userId in
|
|||
return oops.New(err, "failed to fetch user's projects")
|
||||
}
|
||||
|
||||
if len(projectIDsToDelete) > 0 {
|
||||
var projectIds []int
|
||||
for _, p := range toDelete {
|
||||
projectIds = append(projectIds, p.(*models.Project).ID)
|
||||
}
|
||||
|
||||
if len(projectIds) > 0 {
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
DELETE FROM project WHERE id = ANY($1)
|
||||
DELETE FROM handmade_project WHERE id = ANY($1)
|
||||
`,
|
||||
projectIDsToDelete,
|
||||
projectIds,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to delete user's projects")
|
||||
|
|
|
@ -19,13 +19,17 @@ func APICheckUsername(c *RequestContext) ResponseData {
|
|||
requestedUsername := usernameArgs[0]
|
||||
found = true
|
||||
c.Perf.StartBlock("SQL", "Fetch user")
|
||||
user, err := db.QueryOne[models.User](c.Context(), c.Conn,
|
||||
type userQuery struct {
|
||||
User models.User `db:"auth_user"`
|
||||
}
|
||||
userResult, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
hmn_user
|
||||
auth_user
|
||||
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
|
||||
WHERE
|
||||
LOWER(hmn_user.username) = LOWER($1)
|
||||
LOWER(auth_user.username) = LOWER($1)
|
||||
AND status = ANY ($2)
|
||||
`,
|
||||
requestedUsername,
|
||||
|
@ -39,7 +43,7 @@ func APICheckUsername(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", requestedUsername))
|
||||
}
|
||||
} else {
|
||||
canonicalUsername = user.Username
|
||||
canonicalUsername = userResult.(*userQuery).User.Username
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -75,10 +75,14 @@ func Login(c *RequestContext) ResponseData {
|
|||
return res
|
||||
}
|
||||
|
||||
user, err := db.QueryOne[models.User](c.Context(), c.Conn,
|
||||
type userQuery struct {
|
||||
User models.User `db:"auth_user"`
|
||||
}
|
||||
userRow, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM hmn_user
|
||||
FROM auth_user
|
||||
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
|
||||
WHERE LOWER(username) = LOWER($1)
|
||||
`,
|
||||
username,
|
||||
|
@ -90,6 +94,7 @@ func Login(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
|
||||
}
|
||||
}
|
||||
user := &userRow.(*userQuery).User
|
||||
|
||||
success, err := tryLogin(c, user, password)
|
||||
|
||||
|
@ -169,10 +174,10 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
|||
|
||||
c.Perf.StartBlock("SQL", "Check for existing usernames and emails")
|
||||
userAlreadyExists := true
|
||||
_, err := db.QueryOneScalar[int](c.Context(), c.Conn,
|
||||
_, err := db.QueryInt(c.Context(), c.Conn,
|
||||
`
|
||||
SELECT id
|
||||
FROM hmn_user
|
||||
FROM auth_user
|
||||
WHERE LOWER(username) = LOWER($1)
|
||||
`,
|
||||
username,
|
||||
|
@ -190,10 +195,10 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
emailAlreadyExists := true
|
||||
_, err = db.QueryOneScalar[int](c.Context(), c.Conn,
|
||||
_, err = db.QueryInt(c.Context(), c.Conn,
|
||||
`
|
||||
SELECT id
|
||||
FROM hmn_user
|
||||
FROM auth_user
|
||||
WHERE LOWER(email) = LOWER($1)
|
||||
`,
|
||||
emailAddress,
|
||||
|
@ -226,7 +231,7 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
|||
var newUserId int
|
||||
err = tx.QueryRow(c.Context(),
|
||||
`
|
||||
INSERT INTO hmn_user (username, email, password, date_joined, name, registration_ip)
|
||||
INSERT INTO auth_user (username, email, password, date_joined, name, registration_ip)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
`,
|
||||
|
@ -239,7 +244,7 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
|||
ott := models.GenerateToken()
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
INSERT INTO one_time_token (token_type, created, expires, token_content, owner_id)
|
||||
INSERT INTO handmade_onetimetoken (token_type, created, expires, token_content, owner_id)
|
||||
VALUES($1, $2, $3, $4, $5)
|
||||
`,
|
||||
models.TokenTypeRegistration,
|
||||
|
@ -374,7 +379,7 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
|
|||
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE hmn_user
|
||||
UPDATE auth_user
|
||||
SET status = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
|
@ -387,7 +392,7 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
|
|||
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
DELETE FROM one_time_token WHERE id = $1
|
||||
DELETE FROM handmade_onetimetoken WHERE id = $1
|
||||
`,
|
||||
validationResult.OneTimeToken.ID,
|
||||
)
|
||||
|
@ -449,14 +454,17 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
return RejectRequest(c, "You must provide a username and an email address.")
|
||||
}
|
||||
|
||||
var user *models.User
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetching user")
|
||||
type userQuery struct {
|
||||
User models.User `db:"hmn_user"`
|
||||
User models.User `db:"auth_user"`
|
||||
}
|
||||
user, err := db.QueryOne[models.User](c.Context(), c.Conn,
|
||||
userRow, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM hmn_user
|
||||
FROM auth_user
|
||||
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
|
||||
WHERE
|
||||
LOWER(username) = LOWER($1)
|
||||
AND LOWER(email) = LOWER($2)
|
||||
|
@ -470,13 +478,16 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
|
||||
}
|
||||
}
|
||||
if userRow != nil {
|
||||
user = &userRow.(*userQuery).User
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
c.Perf.StartBlock("SQL", "Fetching existing token")
|
||||
resetToken, err := db.QueryOne[models.OneTimeToken](c.Context(), c.Conn,
|
||||
tokenRow, err := db.QueryOne(c.Context(), c.Conn, models.OneTimeToken{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM one_time_token
|
||||
FROM handmade_onetimetoken
|
||||
WHERE
|
||||
token_type = $1
|
||||
AND owner_id = $2
|
||||
|
@ -490,6 +501,10 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch onetimetoken for user"))
|
||||
}
|
||||
}
|
||||
var resetToken *models.OneTimeToken
|
||||
if tokenRow != nil {
|
||||
resetToken = tokenRow.(*models.OneTimeToken)
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
if resetToken != nil {
|
||||
|
@ -497,7 +512,7 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
c.Perf.StartBlock("SQL", "Deleting expired token")
|
||||
_, err = c.Conn.Exec(c.Context(),
|
||||
`
|
||||
DELETE FROM one_time_token
|
||||
DELETE FROM handmade_onetimetoken
|
||||
WHERE id = $1
|
||||
`,
|
||||
resetToken.ID,
|
||||
|
@ -512,9 +527,9 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
|
||||
if resetToken == nil {
|
||||
c.Perf.StartBlock("SQL", "Creating new token")
|
||||
newToken, err := db.QueryOne[models.OneTimeToken](c.Context(), c.Conn,
|
||||
tokenRow, err := db.QueryOne(c.Context(), c.Conn, models.OneTimeToken{},
|
||||
`
|
||||
INSERT INTO one_time_token (token_type, created, expires, token_content, owner_id)
|
||||
INSERT INTO handmade_onetimetoken (token_type, created, expires, token_content, owner_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING $columns
|
||||
`,
|
||||
|
@ -528,7 +543,7 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create onetimetoken"))
|
||||
}
|
||||
resetToken = newToken
|
||||
resetToken = tokenRow.(*models.OneTimeToken)
|
||||
|
||||
err = email.SendPasswordReset(user.Email, user.BestName(), user.Username, resetToken.Content, resetToken.Expires, c.Perf)
|
||||
if err != nil {
|
||||
|
@ -626,7 +641,7 @@ func DoPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
|
||||
tag, err := tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE hmn_user
|
||||
UPDATE auth_user
|
||||
SET password = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
|
@ -640,7 +655,7 @@ func DoPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
if validationResult.User.Status == models.UserStatusInactive {
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE hmn_user
|
||||
UPDATE auth_user
|
||||
SET status = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
|
@ -654,7 +669,7 @@ func DoPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
DELETE FROM one_time_token
|
||||
DELETE FROM handmade_onetimetoken
|
||||
WHERE id = $1
|
||||
`,
|
||||
validationResult.OneTimeToken.ID,
|
||||
|
@ -721,7 +736,7 @@ func loginUser(c *RequestContext, user *models.User, responseData *ResponseData)
|
|||
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE hmn_user
|
||||
UPDATE auth_user
|
||||
SET last_login = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
|
@ -769,17 +784,17 @@ func validateUsernameAndToken(c *RequestContext, username string, token string,
|
|||
c.Perf.StartBlock("SQL", "Check username and token")
|
||||
defer c.Perf.EndBlock()
|
||||
type userAndTokenQuery struct {
|
||||
User models.User `db:"hmn_user"`
|
||||
User models.User `db:"auth_user"`
|
||||
OneTimeToken *models.OneTimeToken `db:"onetimetoken"`
|
||||
}
|
||||
data, err := db.QueryOne[userAndTokenQuery](c.Context(), c.Conn,
|
||||
row, err := db.QueryOne(c.Context(), c.Conn, userAndTokenQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM hmn_user
|
||||
LEFT JOIN asset AS hmn_user_avatar ON hmn_user_avatar.id = hmn_user.avatar_asset_id
|
||||
LEFT JOIN one_time_token AS onetimetoken ON onetimetoken.owner_id = hmn_user.id
|
||||
FROM auth_user
|
||||
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
|
||||
LEFT JOIN handmade_onetimetoken AS onetimetoken ON onetimetoken.owner_id = auth_user.id
|
||||
WHERE
|
||||
LOWER(hmn_user.username) = LOWER($1)
|
||||
LOWER(auth_user.username) = LOWER($1)
|
||||
AND onetimetoken.token_type = $2
|
||||
`,
|
||||
username,
|
||||
|
@ -792,7 +807,8 @@ func validateUsernameAndToken(c *RequestContext, username string, token string,
|
|||
return result
|
||||
}
|
||||
}
|
||||
if data != nil {
|
||||
if row != nil {
|
||||
data := row.(*userAndTokenQuery)
|
||||
result.User = &data.User
|
||||
result.OneTimeToken = data.OneTimeToken
|
||||
if result.OneTimeToken != nil {
|
||||
|
|
|
@ -155,7 +155,7 @@ func BlogThread(c *RequestContext) ResponseData {
|
|||
c.Perf.StartBlock("SQL", "Update TLRI")
|
||||
_, err := c.Conn.Exec(c.Context(),
|
||||
`
|
||||
INSERT INTO thread_last_read_info (thread_id, user_id, lastread)
|
||||
INSERT INTO handmade_threadlastreadinfo (thread_id, user_id, lastread)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (thread_id, user_id) DO UPDATE
|
||||
SET lastread = EXCLUDED.lastread
|
||||
|
@ -248,7 +248,7 @@ func BlogNewThreadSubmit(c *RequestContext) ResponseData {
|
|||
var threadId int
|
||||
err = tx.QueryRow(c.Context(),
|
||||
`
|
||||
INSERT INTO thread (title, type, project_id, first_id, last_id)
|
||||
INSERT INTO handmade_thread (title, type, project_id, first_id, last_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`,
|
||||
|
@ -360,7 +360,7 @@ func BlogPostEditSubmit(c *RequestContext) ResponseData {
|
|||
if title != "" {
|
||||
_, err := tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE thread SET title = $1 WHERE id = $2
|
||||
UPDATE handmade_thread SET title = $1 WHERE id = $2
|
||||
`,
|
||||
title,
|
||||
post.Thread.ID,
|
||||
|
@ -558,10 +558,10 @@ func getCommonBlogData(c *RequestContext) (commonBlogData, bool) {
|
|||
res.ThreadID = threadId
|
||||
|
||||
c.Perf.StartBlock("SQL", "Verify that the thread exists")
|
||||
threadExists, err := db.QueryOneScalar[bool](c.Context(), c.Conn,
|
||||
threadExists, err := db.QueryBool(c.Context(), c.Conn,
|
||||
`
|
||||
SELECT COUNT(*) > 0
|
||||
FROM thread
|
||||
FROM handmade_thread
|
||||
WHERE
|
||||
id = $1
|
||||
AND project_id = $2
|
||||
|
@ -586,10 +586,10 @@ func getCommonBlogData(c *RequestContext) (commonBlogData, bool) {
|
|||
res.PostID = postId
|
||||
|
||||
c.Perf.StartBlock("SQL", "Verify that the post exists")
|
||||
postExists, err := db.QueryOneScalar[bool](c.Context(), c.Conn,
|
||||
postExists, err := db.QueryBool(c.Context(), c.Conn,
|
||||
`
|
||||
SELECT COUNT(*) > 0
|
||||
FROM post
|
||||
FROM handmade_post
|
||||
WHERE
|
||||
id = $1
|
||||
AND thread_id = $2
|
||||
|
|
|
@ -61,7 +61,7 @@ func DiscordOAuthCallback(c *RequestContext) ResponseData {
|
|||
// Add the user to our database
|
||||
_, err = c.Conn.Exec(c.Context(),
|
||||
`
|
||||
INSERT INTO discord_user (username, discriminator, access_token, refresh_token, avatar, locale, userid, expiry, hmn_user_id)
|
||||
INSERT INTO handmade_discorduser (username, discriminator, access_token, refresh_token, avatar, locale, userid, expiry, hmn_user_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`,
|
||||
user.Username,
|
||||
|
@ -81,7 +81,7 @@ func DiscordOAuthCallback(c *RequestContext) ResponseData {
|
|||
if c.CurrentUser.Status == models.UserStatusConfirmed {
|
||||
_, err = c.Conn.Exec(c.Context(),
|
||||
`
|
||||
UPDATE hmn_user
|
||||
UPDATE auth_user
|
||||
SET status = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
|
@ -104,10 +104,10 @@ func DiscordUnlink(c *RequestContext) ResponseData {
|
|||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
|
||||
discordUser, err := db.QueryOne[models.DiscordUser](c.Context(), tx,
|
||||
iDiscordUser, err := db.QueryOne(c.Context(), tx, models.DiscordUser{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM discord_user
|
||||
FROM handmade_discorduser
|
||||
WHERE hmn_user_id = $1
|
||||
`,
|
||||
c.CurrentUser.ID,
|
||||
|
@ -119,10 +119,11 @@ func DiscordUnlink(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get Discord user for unlink"))
|
||||
}
|
||||
}
|
||||
discordUser := iDiscordUser.(*models.DiscordUser)
|
||||
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
DELETE FROM discord_user
|
||||
DELETE FROM handmade_discorduser
|
||||
WHERE id = $1
|
||||
`,
|
||||
discordUser.ID,
|
||||
|
@ -145,8 +146,8 @@ func DiscordUnlink(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
func DiscordShowcaseBacklog(c *RequestContext) ResponseData {
|
||||
duser, err := db.QueryOne[models.DiscordUser](c.Context(), c.Conn,
|
||||
`SELECT $columns FROM discord_user WHERE hmn_user_id = $1`,
|
||||
iduser, err := db.QueryOne(c.Context(), c.Conn, models.DiscordUser{},
|
||||
`SELECT $columns FROM handmade_discorduser WHERE hmn_user_id = $1`,
|
||||
c.CurrentUser.ID,
|
||||
)
|
||||
if errors.Is(err, db.NotFound) {
|
||||
|
@ -156,12 +157,16 @@ func DiscordShowcaseBacklog(c *RequestContext) ResponseData {
|
|||
} else if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get discord user"))
|
||||
}
|
||||
duser := iduser.(*models.DiscordUser)
|
||||
|
||||
msgIDs, err := db.QueryScalar[string](c.Context(), c.Conn,
|
||||
type messageIdQuery struct {
|
||||
MessageID string `db:"msg.id"`
|
||||
}
|
||||
iMsgIDs, err := db.Query(c.Context(), c.Conn, messageIdQuery{},
|
||||
`
|
||||
SELECT msg.id
|
||||
SELECT $columns
|
||||
FROM
|
||||
discord_message AS msg
|
||||
handmade_discordmessage AS msg
|
||||
WHERE
|
||||
msg.user_id = $1
|
||||
AND msg.channel_id = $2
|
||||
|
@ -173,6 +178,10 @@ func DiscordShowcaseBacklog(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
var msgIDs []string
|
||||
for _, imsgId := range iMsgIDs {
|
||||
msgIDs = append(msgIDs, imsgId.(*messageIdQuery).MessageID)
|
||||
}
|
||||
for _, msgID := range msgIDs {
|
||||
interned, err := discord.FetchInternedMessage(c.Context(), c.Conn, msgID)
|
||||
if err != nil && !errors.Is(err, db.NotFound) {
|
||||
|
|
|
@ -223,7 +223,7 @@ func ForumMarkRead(c *RequestContext) ResponseData {
|
|||
// Mark literally everything as read
|
||||
_, err := tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE hmn_user
|
||||
UPDATE auth_user
|
||||
SET marked_all_read_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
|
@ -236,7 +236,7 @@ func ForumMarkRead(c *RequestContext) ResponseData {
|
|||
// Delete thread unread info
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
DELETE FROM thread_last_read_info
|
||||
DELETE FROM handmade_threadlastreadinfo
|
||||
WHERE user_id = $1;
|
||||
`,
|
||||
c.CurrentUser.ID,
|
||||
|
@ -248,7 +248,7 @@ func ForumMarkRead(c *RequestContext) ResponseData {
|
|||
// Delete subforum unread info
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
DELETE FROM subforum_last_read_info
|
||||
DELETE FROM handmade_subforumlastreadinfo
|
||||
WHERE user_id = $1;
|
||||
`,
|
||||
c.CurrentUser.ID,
|
||||
|
@ -260,9 +260,9 @@ func ForumMarkRead(c *RequestContext) ResponseData {
|
|||
c.Perf.StartBlock("SQL", "Update SLRIs")
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
INSERT INTO subforum_last_read_info (subforum_id, user_id, lastread)
|
||||
INSERT INTO handmade_subforumlastreadinfo (subforum_id, user_id, lastread)
|
||||
SELECT id, $2, $3
|
||||
FROM subforum
|
||||
FROM handmade_subforum
|
||||
WHERE id = ANY ($1)
|
||||
ON CONFLICT (subforum_id, user_id) DO UPDATE
|
||||
SET lastread = EXCLUDED.lastread
|
||||
|
@ -279,12 +279,12 @@ func ForumMarkRead(c *RequestContext) ResponseData {
|
|||
c.Perf.StartBlock("SQL", "Delete TLRIs")
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
DELETE FROM thread_last_read_info
|
||||
DELETE FROM handmade_threadlastreadinfo
|
||||
WHERE
|
||||
user_id = $2
|
||||
AND thread_id IN (
|
||||
SELECT id
|
||||
FROM thread
|
||||
FROM handmade_thread
|
||||
WHERE
|
||||
subforum_id = ANY ($1)
|
||||
)
|
||||
|
@ -402,7 +402,7 @@ func ForumThread(c *RequestContext) ResponseData {
|
|||
c.Perf.StartBlock("SQL", "Update TLRI")
|
||||
_, err = c.Conn.Exec(c.Context(),
|
||||
`
|
||||
INSERT INTO thread_last_read_info (thread_id, user_id, lastread)
|
||||
INSERT INTO handmade_threadlastreadinfo (thread_id, user_id, lastread)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (thread_id, user_id) DO UPDATE
|
||||
SET lastread = EXCLUDED.lastread
|
||||
|
@ -523,7 +523,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
|
|||
var threadId int
|
||||
err = tx.QueryRow(c.Context(),
|
||||
`
|
||||
INSERT INTO thread (title, sticky, type, project_id, subforum_id, first_id, last_id)
|
||||
INSERT INTO handmade_thread (title, sticky, type, project_id, subforum_id, first_id, last_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
`,
|
||||
|
@ -710,7 +710,7 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
|
|||
if title != "" {
|
||||
_, err := tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE thread SET title = $1 WHERE id = $2
|
||||
UPDATE handmade_thread SET title = $1 WHERE id = $2
|
||||
`,
|
||||
title,
|
||||
post.Thread.ID,
|
||||
|
@ -936,12 +936,12 @@ func addForumUrlsToPost(urlContext *hmnurl.UrlContext, p *templates.Post, subfor
|
|||
// Takes a template post and adds information about how many posts the user has made
|
||||
// on the site.
|
||||
func addAuthorCountsToPost(ctx context.Context, conn db.ConnOrTx, p *templates.Post) {
|
||||
numPosts, err := db.QueryOneScalar[int](ctx, conn,
|
||||
numPosts, err := db.QueryInt(ctx, conn,
|
||||
`
|
||||
SELECT COUNT(*)
|
||||
FROM
|
||||
post
|
||||
JOIN project ON post.project_id = project.id
|
||||
handmade_post AS post
|
||||
JOIN handmade_project AS project ON post.project_id = project.id
|
||||
WHERE
|
||||
post.author_id = $1
|
||||
AND NOT post.deleted
|
||||
|
@ -956,12 +956,12 @@ func addAuthorCountsToPost(ctx context.Context, conn db.ConnOrTx, p *templates.P
|
|||
p.AuthorNumPosts = numPosts
|
||||
}
|
||||
|
||||
numProjects, err := db.QueryOneScalar[int](ctx, conn,
|
||||
numProjects, err := db.QueryInt(ctx, conn,
|
||||
`
|
||||
SELECT COUNT(*)
|
||||
FROM
|
||||
project
|
||||
JOIN user_project AS uproj ON uproj.project_id = project.id
|
||||
handmade_project AS project
|
||||
JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id
|
||||
WHERE
|
||||
project.lifecycle = ANY ($1)
|
||||
AND uproj.user_id = $2
|
||||
|
|
|
@ -89,9 +89,10 @@ func SaveImageFile(c *RequestContext, dbConn db.ConnOrTx, fileFieldName string,
|
|||
img.Seek(0, io.SeekStart)
|
||||
io.Copy(hasher, img) // NOTE(asaf): Writing to hash.Hash never returns an error according to the docs
|
||||
sha1sum := hasher.Sum(nil)
|
||||
imageFile, err := db.QueryOne[models.ImageFile](c.Context(), dbConn,
|
||||
// TODO(db): Should use insert helper
|
||||
imageFile, err := db.QueryOne(c.Context(), dbConn, models.ImageFile{},
|
||||
`
|
||||
INSERT INTO image_file (file, size, sha1sum, protected, width, height)
|
||||
INSERT INTO handmade_imagefile (file, size, sha1sum, protected, width, height)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING $columns
|
||||
`,
|
||||
|
@ -104,7 +105,7 @@ func SaveImageFile(c *RequestContext, dbConn db.ConnOrTx, fileFieldName string,
|
|||
}
|
||||
|
||||
return SaveImageFileResult{
|
||||
ImageFile: imageFile,
|
||||
ImageFile: imageFile.(*models.ImageFile),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -30,9 +30,10 @@ func ParseLinks(text string) []ParsedLink {
|
|||
return res
|
||||
}
|
||||
|
||||
func LinksToText(links []*models.Link) string {
|
||||
func LinksToText(links []interface{}) string {
|
||||
linksText := ""
|
||||
for _, link := range links {
|
||||
for _, l := range links {
|
||||
link := l.(*models.Link)
|
||||
linksText += fmt.Sprintf("%s %s\n", link.URL, link.Name)
|
||||
}
|
||||
return linksText
|
||||
|
|
|
@ -3,8 +3,6 @@ package website
|
|||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
)
|
||||
|
||||
func getPageInfo(
|
||||
|
@ -16,7 +14,7 @@ func getPageInfo(
|
|||
totalPages int,
|
||||
ok bool,
|
||||
) {
|
||||
totalPages = utils.IntMax(1, int(math.Ceil(float64(totalItems)/float64(itemsPerPage))))
|
||||
totalPages = int(math.Ceil(float64(totalItems) / float64(itemsPerPage)))
|
||||
ok = true
|
||||
|
||||
page = 1
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetPageInfo(t *testing.T) {
|
||||
items := []struct {
|
||||
name string
|
||||
pageParam string
|
||||
totalItems, perPage int
|
||||
page, totalPages int
|
||||
ok bool
|
||||
}{
|
||||
{"good, no param", "", 85, 10, 1, 9, true},
|
||||
{"good", "2", 85, 10, 2, 9, true},
|
||||
{"too big", "10", 85, 10, 0, 0, false},
|
||||
{"too small", "0", 85, 10, 0, 0, false},
|
||||
{"pizza", "pizza", 85, 10, 0, 0, false},
|
||||
{"zero items, no param", "", 0, 10, 1, 1, true}, // should go to page 1
|
||||
{"zero items, page 1", "1", 0, 10, 1, 1, true},
|
||||
{"zero items, too big", "2", 0, 10, 0, 0, false},
|
||||
{"zero items, too small", "0", 0, 10, 0, 0, false},
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
page, totalPages, ok := getPageInfo(item.pageParam, item.totalItems, item.perPage)
|
||||
assert.Equal(t, item.page, page)
|
||||
assert.Equal(t, item.totalPages, totalPages)
|
||||
assert.Equal(t, item.ok, ok)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -150,7 +150,7 @@ func PodcastEditSubmit(c *RequestContext) ResponseData {
|
|||
if imageSaveResult.ImageFile != nil {
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE podcast
|
||||
UPDATE handmade_podcast
|
||||
SET
|
||||
title = $1,
|
||||
description = $2,
|
||||
|
@ -168,7 +168,7 @@ func PodcastEditSubmit(c *RequestContext) ResponseData {
|
|||
} else {
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE podcast
|
||||
UPDATE handmade_podcast
|
||||
SET
|
||||
title = $1,
|
||||
description = $2
|
||||
|
@ -419,7 +419,7 @@ func PodcastEpisodeSubmit(c *RequestContext) ResponseData {
|
|||
c.Perf.StartBlock("SQL", "Updating podcast episode")
|
||||
_, err := c.Conn.Exec(c.Context(),
|
||||
`
|
||||
UPDATE podcast_episode
|
||||
UPDATE handmade_podcastepisode
|
||||
SET
|
||||
title = $1,
|
||||
description = $2,
|
||||
|
@ -448,7 +448,7 @@ func PodcastEpisodeSubmit(c *RequestContext) ResponseData {
|
|||
c.Perf.StartBlock("SQL", "Creating new podcast episode")
|
||||
_, err := c.Conn.Exec(c.Context(),
|
||||
`
|
||||
INSERT INTO podcast_episode
|
||||
INSERT INTO handmade_podcastepisode
|
||||
(guid, title, description, description_rendered, audio_filename, duration, pub_date, episode_number, podcast_id)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
|
@ -532,12 +532,11 @@ func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeG
|
|||
Podcast models.Podcast `db:"podcast"`
|
||||
ImageFilename string `db:"imagefile.file"`
|
||||
}
|
||||
podcastQueryResult, err := db.QueryOne[podcastQuery](c.Context(), c.Conn,
|
||||
podcastQueryResult, err := db.QueryOne(c.Context(), c.Conn, podcastQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
podcast
|
||||
LEFT JOIN image_file AS imagefile ON imagefile.id = podcast.image_id
|
||||
FROM handmade_podcast AS podcast
|
||||
LEFT JOIN handmade_imagefile AS imagefile ON imagefile.id = podcast.image_id
|
||||
WHERE podcast.project_id = $1
|
||||
`,
|
||||
projectId,
|
||||
|
@ -550,18 +549,21 @@ func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeG
|
|||
return result, oops.New(err, "failed to fetch podcast")
|
||||
}
|
||||
}
|
||||
podcast := podcastQueryResult.Podcast
|
||||
podcastImageFilename := podcastQueryResult.ImageFilename
|
||||
podcast := podcastQueryResult.(*podcastQuery).Podcast
|
||||
podcastImageFilename := podcastQueryResult.(*podcastQuery).ImageFilename
|
||||
result.Podcast = &podcast
|
||||
result.ImageFile = podcastImageFilename
|
||||
|
||||
if fetchEpisodes {
|
||||
type podcastEpisodeQuery struct {
|
||||
Episode models.PodcastEpisode `db:"episode"`
|
||||
}
|
||||
if episodeGUID == "" {
|
||||
c.Perf.StartBlock("SQL", "Fetch podcast episodes")
|
||||
episodes, err := db.Query[models.PodcastEpisode](c.Context(), c.Conn,
|
||||
podcastEpisodeQueryResult, err := db.Query(c.Context(), c.Conn, podcastEpisodeQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM podcast_episode AS episode
|
||||
FROM handmade_podcastepisode AS episode
|
||||
WHERE episode.podcast_id = $1
|
||||
ORDER BY episode.season_number DESC, episode.episode_number DESC
|
||||
`,
|
||||
|
@ -571,17 +573,19 @@ func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeG
|
|||
if err != nil {
|
||||
return result, oops.New(err, "failed to fetch podcast episodes")
|
||||
}
|
||||
result.Episodes = episodes
|
||||
for _, episodeRow := range podcastEpisodeQueryResult {
|
||||
result.Episodes = append(result.Episodes, &episodeRow.(*podcastEpisodeQuery).Episode)
|
||||
}
|
||||
} else {
|
||||
guid, err := uuid.Parse(episodeGUID)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
c.Perf.StartBlock("SQL", "Fetch podcast episode")
|
||||
episode, err := db.QueryOne[models.PodcastEpisode](c.Context(), c.Conn,
|
||||
podcastEpisodeQueryResult, err := db.QueryOne(c.Context(), c.Conn, podcastEpisodeQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM podcast_episode AS episode
|
||||
FROM handmade_podcastepisode AS episode
|
||||
WHERE episode.podcast_id = $1 AND episode.guid = $2
|
||||
`,
|
||||
podcast.ID,
|
||||
|
@ -595,7 +599,8 @@ func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeG
|
|||
return result, oops.New(err, "failed to fetch podcast episode")
|
||||
}
|
||||
}
|
||||
result.Episodes = append(result.Episodes, episode)
|
||||
episode := podcastEpisodeQueryResult.(*podcastEpisodeQuery).Episode
|
||||
result.Episodes = append(result.Episodes, &episode)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -187,14 +187,17 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetching screenshots")
|
||||
screenshotFilenames, err := db.QueryScalar[string](c.Context(), c.Conn,
|
||||
type screenshotQuery struct {
|
||||
Filename string `db:"screenshot.file"`
|
||||
}
|
||||
screenshotQueryResult, err := db.Query(c.Context(), c.Conn, screenshotQuery{},
|
||||
`
|
||||
SELECT screenshot.file
|
||||
SELECT $columns
|
||||
FROM
|
||||
image_file AS screenshot
|
||||
INNER JOIN project_screenshot ON screenshot.id = project_screenshot.imagefile_id
|
||||
handmade_imagefile AS screenshot
|
||||
INNER JOIN handmade_project_screenshots ON screenshot.id = handmade_project_screenshots.imagefile_id
|
||||
WHERE
|
||||
project_screenshot.project_id = $1
|
||||
handmade_project_screenshots.project_id = $1
|
||||
`,
|
||||
c.CurrentProject.ID,
|
||||
)
|
||||
|
@ -204,11 +207,14 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
c.Perf.EndBlock()
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetching project links")
|
||||
projectLinks, err := db.Query[models.Link](c.Context(), c.Conn,
|
||||
type projectLinkQuery struct {
|
||||
Link models.Link `db:"link"`
|
||||
}
|
||||
projectLinkResult, err := db.Query(c.Context(), c.Conn, projectLinkQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
link as link
|
||||
handmade_links as link
|
||||
WHERE
|
||||
link.project_id = $1
|
||||
ORDER BY link.ordering ASC
|
||||
|
@ -231,14 +237,14 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
Thread models.Thread `db:"thread"`
|
||||
Author models.User `db:"author"`
|
||||
}
|
||||
posts, err := db.Query[postQuery](c.Context(), c.Conn,
|
||||
postQueryResult, err := db.Query(c.Context(), c.Conn, postQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
post
|
||||
INNER JOIN thread ON thread.id = post.thread_id
|
||||
INNER JOIN hmn_user AS author ON author.id = post.author_id
|
||||
LEFT JOIN asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
|
||||
handmade_post AS post
|
||||
INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id
|
||||
INNER JOIN auth_user AS author ON author.id = post.author_id
|
||||
LEFT JOIN handmade_asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
|
||||
WHERE
|
||||
post.project_id = $1
|
||||
ORDER BY post.postdate DESC
|
||||
|
@ -312,21 +318,21 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
}
|
||||
}
|
||||
|
||||
for _, screenshotFilename := range screenshotFilenames {
|
||||
templateData.Screenshots = append(templateData.Screenshots, hmnurl.BuildUserFile(screenshotFilename))
|
||||
for _, screenshot := range screenshotQueryResult {
|
||||
templateData.Screenshots = append(templateData.Screenshots, hmnurl.BuildUserFile(screenshot.(*screenshotQuery).Filename))
|
||||
}
|
||||
|
||||
for _, link := range projectLinks {
|
||||
templateData.ProjectLinks = append(templateData.ProjectLinks, templates.LinkToTemplate(link))
|
||||
for _, link := range projectLinkResult {
|
||||
templateData.ProjectLinks = append(templateData.ProjectLinks, templates.LinkToTemplate(&link.(*projectLinkQuery).Link))
|
||||
}
|
||||
|
||||
for _, post := range posts {
|
||||
for _, post := range postQueryResult {
|
||||
templateData.RecentActivity = append(templateData.RecentActivity, PostToTimelineItem(
|
||||
c.UrlContext,
|
||||
lineageBuilder,
|
||||
&post.Post,
|
||||
&post.Thread,
|
||||
&post.Author,
|
||||
&post.(*postQuery).Post,
|
||||
&post.(*postQuery).Thread,
|
||||
&post.(*postQuery).Author,
|
||||
c.Theme,
|
||||
))
|
||||
}
|
||||
|
@ -439,7 +445,7 @@ func ProjectNewSubmit(c *RequestContext) ResponseData {
|
|||
var projectId int
|
||||
err = tx.QueryRow(c.Context(),
|
||||
`
|
||||
INSERT INTO project
|
||||
INSERT INTO handmade_project
|
||||
(name, blurb, description, descparsed, lifecycle, date_created, all_last_updated)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $6)
|
||||
|
@ -492,11 +498,11 @@ func ProjectEdit(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetching project links")
|
||||
projectLinks, err := db.Query[models.Link](c.Context(), c.Conn,
|
||||
projectLinkResult, err := db.Query(c.Context(), c.Conn, models.Link{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
link as link
|
||||
handmade_links as link
|
||||
WHERE
|
||||
link.project_id = $1
|
||||
ORDER BY link.ordering ASC
|
||||
|
@ -519,7 +525,7 @@ func ProjectEdit(c *RequestContext) ResponseData {
|
|||
c.Theme,
|
||||
)
|
||||
|
||||
projectSettings.LinksText = LinksToText(projectLinks)
|
||||
projectSettings.LinksText = LinksToText(projectLinkResult)
|
||||
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("project_edit.html", ProjectEditData{
|
||||
|
@ -736,7 +742,7 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
|||
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
UPDATE project SET
|
||||
UPDATE handmade_project SET
|
||||
name = $2,
|
||||
blurb = $3,
|
||||
description = $4,
|
||||
|
@ -763,7 +769,7 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
|||
if user.IsStaff {
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE project SET
|
||||
UPDATE handmade_project SET
|
||||
slug = $2,
|
||||
featured = $3,
|
||||
personal = $4,
|
||||
|
@ -785,7 +791,7 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
|||
if payload.LightLogo.Exists || payload.LightLogo.Remove {
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE project
|
||||
UPDATE handmade_project
|
||||
SET
|
||||
logolight_asset_id = $2
|
||||
WHERE
|
||||
|
@ -802,7 +808,7 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
|||
if payload.DarkLogo.Exists || payload.DarkLogo.Remove {
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE project
|
||||
UPDATE handmade_project
|
||||
SET
|
||||
logodark_asset_id = $2
|
||||
WHERE
|
||||
|
@ -816,10 +822,14 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
|||
}
|
||||
}
|
||||
|
||||
owners, err := db.Query[models.User](ctx, tx,
|
||||
type userQuery struct {
|
||||
User models.User `db:"auth_user"`
|
||||
}
|
||||
ownerRows, err := db.Query(ctx, tx, userQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM hmn_user
|
||||
FROM auth_user
|
||||
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
|
||||
WHERE LOWER(username) = ANY ($1)
|
||||
`,
|
||||
payload.OwnerUsernames,
|
||||
|
@ -830,7 +840,7 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
|||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
DELETE FROM user_project
|
||||
DELETE FROM handmade_user_projects
|
||||
WHERE project_id = $1
|
||||
`,
|
||||
payload.ProjectID,
|
||||
|
@ -839,15 +849,15 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
|||
return oops.New(err, "Failed to delete project owners")
|
||||
}
|
||||
|
||||
for _, owner := range owners {
|
||||
for _, ownerRow := range ownerRows {
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO user_project
|
||||
INSERT INTO handmade_user_projects
|
||||
(user_id, project_id)
|
||||
VALUES
|
||||
($1, $2)
|
||||
`,
|
||||
owner.ID,
|
||||
ownerRow.(*userQuery).User.ID,
|
||||
payload.ProjectID,
|
||||
)
|
||||
if err != nil {
|
||||
|
@ -856,14 +866,14 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
|||
}
|
||||
|
||||
twitchLoginsPreChange, preErr := hmndata.FetchTwitchLoginsForUserOrProject(ctx, tx, nil, &payload.ProjectID)
|
||||
_, err = tx.Exec(ctx, `DELETE FROM link WHERE project_id = $1`, payload.ProjectID)
|
||||
_, err = tx.Exec(ctx, `DELETE FROM handmade_links WHERE project_id = $1`, payload.ProjectID)
|
||||
if err != nil {
|
||||
return oops.New(err, "Failed to delete project links")
|
||||
}
|
||||
for i, link := range payload.Links {
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO link (name, url, ordering, project_id)
|
||||
INSERT INTO handmade_links (name, url, ordering, project_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`,
|
||||
link.Name,
|
||||
|
|
|
@ -548,9 +548,18 @@ func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User
|
|||
}
|
||||
}
|
||||
|
||||
user, err := hmndata.FetchUserByUsername(c.Context(), c.Conn, nil, session.Username, hmndata.UsersQuery{
|
||||
AnyStatus: true,
|
||||
})
|
||||
type userQuery struct {
|
||||
User models.User `db:"auth_user"`
|
||||
}
|
||||
userRow, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM auth_user
|
||||
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
|
||||
WHERE username = $1
|
||||
`,
|
||||
session.Username,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
logging.Debug().Str("username", session.Username).Msg("returning no current user for this request because the user for the session couldn't be found")
|
||||
|
@ -559,6 +568,7 @@ func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User
|
|||
return nil, nil, oops.New(err, "failed to get user for session")
|
||||
}
|
||||
}
|
||||
user := &userRow.(*userQuery).User
|
||||
|
||||
return user, session, nil
|
||||
}
|
||||
|
|
|
@ -16,9 +16,6 @@ func TestLogContextErrors(t *testing.T) {
|
|||
err1 := errors.New("test error 1")
|
||||
err2 := errors.New("test error 2")
|
||||
|
||||
defer zerolog.SetGlobalLevel(zerolog.GlobalLevel())
|
||||
zerolog.SetGlobalLevel(zerolog.TraceLevel)
|
||||
|
||||
var buf bytes.Buffer
|
||||
logger := zerolog.New(&buf)
|
||||
logger.Print("sanity check")
|
||||
|
|
|
@ -70,7 +70,7 @@ func TwitchEventSubCallback(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
func TwitchDebugPage(c *RequestContext) ResponseData {
|
||||
streams, err := db.Query[models.TwitchStream](c.Context(), c.Conn,
|
||||
streams, err := db.Query(c.Context(), c.Conn, models.TwitchStream{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
|
@ -83,7 +83,8 @@ func TwitchDebugPage(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
html := ""
|
||||
for _, s := range streams {
|
||||
for _, stream := range streams {
|
||||
s := stream.(*models.TwitchStream)
|
||||
html += fmt.Sprintf(`<a href="https://twitch.tv/%s">%s</a>%s<br />`, s.Login, s.Login, s.Title)
|
||||
}
|
||||
var res ResponseData
|
||||
|
|
|
@ -52,7 +52,22 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
if c.CurrentUser != nil && strings.ToLower(c.CurrentUser.Username) == username {
|
||||
profileUser = c.CurrentUser
|
||||
} else {
|
||||
user, err := hmndata.FetchUserByUsername(c.Context(), c.Conn, c.CurrentUser, username, hmndata.UsersQuery{})
|
||||
c.Perf.StartBlock("SQL", "Fetch user")
|
||||
type userQuery struct {
|
||||
User models.User `db:"auth_user"`
|
||||
}
|
||||
userResult, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
auth_user
|
||||
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
|
||||
WHERE
|
||||
LOWER(auth_user.username) = $1
|
||||
`,
|
||||
username,
|
||||
)
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
return FourOhFour(c)
|
||||
|
@ -60,7 +75,7 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", username))
|
||||
}
|
||||
}
|
||||
profileUser = user
|
||||
profileUser = &userResult.(*userQuery).User
|
||||
}
|
||||
|
||||
{
|
||||
|
@ -72,11 +87,14 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch user links")
|
||||
userLinks, err := db.Query[models.Link](c.Context(), c.Conn,
|
||||
type userLinkQuery struct {
|
||||
UserLink models.Link `db:"link"`
|
||||
}
|
||||
userLinksSlice, err := db.Query(c.Context(), c.Conn, userLinkQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
link as link
|
||||
handmade_links as link
|
||||
WHERE
|
||||
link.user_id = $1
|
||||
ORDER BY link.ordering ASC
|
||||
|
@ -86,9 +104,9 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch links for user: %s", username))
|
||||
}
|
||||
profileUserLinks := make([]templates.Link, 0, len(userLinks))
|
||||
for _, l := range userLinks {
|
||||
profileUserLinks = append(profileUserLinks, templates.LinkToTemplate(l))
|
||||
profileUserLinks := make([]templates.Link, 0, len(userLinksSlice))
|
||||
for _, l := range userLinksSlice {
|
||||
profileUserLinks = append(profileUserLinks, templates.LinkToTemplate(&l.(*userLinkQuery).UserLink))
|
||||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
|
@ -213,10 +231,10 @@ func UserSettings(c *RequestContext) ResponseData {
|
|||
DiscordShowcaseBacklogUrl string
|
||||
}
|
||||
|
||||
links, err := db.Query[models.Link](c.Context(), c.Conn,
|
||||
links, err := db.Query(c.Context(), c.Conn, models.Link{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM link
|
||||
FROM handmade_links
|
||||
WHERE user_id = $1
|
||||
ORDER BY ordering
|
||||
`,
|
||||
|
@ -230,10 +248,10 @@ func UserSettings(c *RequestContext) ResponseData {
|
|||
|
||||
var tduser *templates.DiscordUser
|
||||
var numUnsavedMessages int
|
||||
duser, err := db.QueryOne[models.DiscordUser](c.Context(), c.Conn,
|
||||
iduser, err := db.QueryOne(c.Context(), c.Conn, models.DiscordUser{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM discord_user
|
||||
FROM handmade_discorduser
|
||||
WHERE hmn_user_id = $1
|
||||
`,
|
||||
c.CurrentUser.ID,
|
||||
|
@ -243,15 +261,16 @@ func UserSettings(c *RequestContext) ResponseData {
|
|||
} else if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user's Discord account"))
|
||||
} else {
|
||||
duser := iduser.(*models.DiscordUser)
|
||||
tmp := templates.DiscordUserToTemplate(duser)
|
||||
tduser = &tmp
|
||||
|
||||
numUnsavedMessages, err = db.QueryOneScalar[int](c.Context(), c.Conn,
|
||||
numUnsavedMessages, err = db.QueryInt(c.Context(), c.Conn,
|
||||
`
|
||||
SELECT COUNT(*)
|
||||
FROM
|
||||
discord_message AS msg
|
||||
LEFT JOIN discord_message_content AS c ON c.message_id = msg.id
|
||||
handmade_discordmessage AS msg
|
||||
LEFT JOIN handmade_discordmessagecontent AS c ON c.message_id = msg.id
|
||||
WHERE
|
||||
msg.user_id = $1
|
||||
AND msg.channel_id = $2
|
||||
|
@ -330,7 +349,7 @@ func UserSettingsSave(c *RequestContext) ResponseData {
|
|||
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE hmn_user
|
||||
UPDATE auth_user
|
||||
SET
|
||||
name = $2,
|
||||
email = $3,
|
||||
|
@ -363,14 +382,14 @@ func UserSettingsSave(c *RequestContext) ResponseData {
|
|||
twitchLoginsPreChange, preErr := hmndata.FetchTwitchLoginsForUserOrProject(c.Context(), tx, &c.CurrentUser.ID, nil)
|
||||
linksText := form.Get("links")
|
||||
links := ParseLinks(linksText)
|
||||
_, err = tx.Exec(c.Context(), `DELETE FROM link WHERE user_id = $1`, c.CurrentUser.ID)
|
||||
_, err = tx.Exec(c.Context(), `DELETE FROM handmade_links WHERE user_id = $1`, c.CurrentUser.ID)
|
||||
if err != nil {
|
||||
c.Logger.Warn().Err(err).Msg("failed to delete old links")
|
||||
} else {
|
||||
for i, link := range links {
|
||||
_, err := tx.Exec(c.Context(),
|
||||
`
|
||||
INSERT INTO link (name, url, ordering, user_id)
|
||||
INSERT INTO handmade_links (name, url, ordering, user_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`,
|
||||
link.Name,
|
||||
|
@ -423,7 +442,7 @@ func UserSettingsSave(c *RequestContext) ResponseData {
|
|||
if newAvatar.Exists || newAvatar.Remove {
|
||||
_, err := tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE hmn_user
|
||||
UPDATE auth_user
|
||||
SET
|
||||
avatar_asset_id = $2
|
||||
WHERE
|
||||
|
@ -474,7 +493,7 @@ func UserProfileAdminSetStatus(c *RequestContext) ResponseData {
|
|||
|
||||
_, err = c.Conn.Exec(c.Context(),
|
||||
`
|
||||
UPDATE hmn_user
|
||||
UPDATE auth_user
|
||||
SET status = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
|
|
|
@ -14,8 +14,6 @@ import (
|
|||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/discord"
|
||||
"git.handmade.network/hmn/hmn/src/hmns3"
|
||||
"git.handmade.network/hmn/hmn/src/jobs"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/perf"
|
||||
"git.handmade.network/hmn/hmn/src/templates"
|
||||
|
@ -35,7 +33,7 @@ var WebsiteCommand = &cobra.Command{
|
|||
backgroundJobContext, cancelBackgroundJobs := context.WithCancel(context.Background())
|
||||
longRequestContext, cancelLongRequests := context.WithCancel(context.Background())
|
||||
|
||||
conn := db.NewConnPool()
|
||||
conn := db.NewConnPool(config.Config.Postgres.MinConn, config.Config.Postgres.MaxConn)
|
||||
perfCollector := perf.RunPerfCollector(backgroundJobContext)
|
||||
|
||||
server := http.Server{
|
||||
|
@ -43,14 +41,13 @@ var WebsiteCommand = &cobra.Command{
|
|||
Handler: NewWebsiteRoutes(longRequestContext, conn),
|
||||
}
|
||||
|
||||
backgroundJobsDone := jobs.Zip(
|
||||
backgroundJobsDone := zipJobs(
|
||||
auth.PeriodicallyDeleteExpiredSessions(backgroundJobContext, conn),
|
||||
auth.PeriodicallyDeleteInactiveUsers(backgroundJobContext, conn),
|
||||
perfCollector.Job,
|
||||
perfCollector.Done,
|
||||
discord.RunDiscordBot(backgroundJobContext, conn),
|
||||
discord.RunHistoryWatcher(backgroundJobContext, conn),
|
||||
twitch.MonitorTwitchSubscriptions(backgroundJobContext, conn),
|
||||
hmns3.StartServer(backgroundJobContext),
|
||||
)
|
||||
|
||||
signals := make(chan os.Signal, 1)
|
||||
|
@ -84,6 +81,17 @@ var WebsiteCommand = &cobra.Command{
|
|||
logging.Error().Err(serverErr).Msg("Server shut down unexpectedly")
|
||||
}
|
||||
|
||||
<-backgroundJobsDone.C
|
||||
<-backgroundJobsDone
|
||||
},
|
||||
}
|
||||
|
||||
func zipJobs(cs ...<-chan struct{}) <-chan struct{} {
|
||||
out := make(chan struct{})
|
||||
go func() {
|
||||
for _, c := range cs {
|
||||
<-c
|
||||
}
|
||||
close(out)
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
|
Reference in New Issue