diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..0d0008d --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,83 @@ +name: Build and publish container images + +on: + push: { branches: [ main ] } + +permissions: + packages: write + +jobs: + build: + runs-on: ubuntu-20.04 + outputs: + commit: ${{ steps.metadata.outputs.commit }} + strategy: + matrix: + architecture: [ amd64, arm64v8 ] + include: + - architecture: amd64 + platform: linux/amd64 + - architecture: arm64v8 + platform: linux/arm64 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Obtain project metadata + id: metadata + run: echo "commit=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Setup QEMU + uses: docker/setup-qemu-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate image metadata + uses: docker/metadata-action@v5 + id: image-metadata + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=${{ matrix.architecture }}-${{ steps.metadata.outputs.commit }},enable={{is_default_branch}} + type=raw,value=${{ matrix.architecture }},enable={{is_default_branch}} + + - name: Build and push platform specific images + uses: docker/build-push-action@v5 + with: + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: ${{ matrix.platform }} + tags: ${{ steps.image-metadata.outputs.tags }} + + merge: + runs-on: ubuntu-20.04 + needs: [ build ] + env: + IMAGE: ghcr.io/${{ github.repository }} + COMMIT: ${{ needs.build.outputs.commit }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate manifest for multi-arch images from source manifests + run: | + docker buildx imagetools create \ + --tag ${IMAGE}:${COMMIT} ${IMAGE}:{amd64,arm64v8}-${COMMIT} + docker buildx imagetools create \ + --tag ${IMAGE}:latest ${IMAGE}:{amd64,arm64v8} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..420f3e3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: Test and Build +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go install github.com/a-h/templ/cmd/templ@latest + - run: make build + #- run: make test diff --git a/.gitignore b/.gitignore index b016f34..10a5ccb 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ dumb +views/*_templ.go +esbuild +static/*.css diff --git a/Dockerfile b/Dockerfile index d063447..7989708 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ -FROM golang:1.19.4-alpine3.17 +FROM golang:1.22.2-alpine3.19 AS build + +RUN apk add make git curl WORKDIR /code @@ -6,8 +8,21 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN go build +RUN make build + +############################################################### + +FROM scratch + +LABEL org.opencontainers.image.source="https://github.com/rramiachraf/dumb" +LABEL org.opencontainers.image.url="https://github.com/rramiachraf/dumb" +LABEL org.opencontainers.image.licenses="MIT" +LABEL org.opencontainers.image.description="Private alternative front-end for Genius." + +COPY --from=build /code/dumb . +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ EXPOSE 5555/tcp -CMD ["/code/dumb"] +CMD ["./dumb"] + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..982ee8e --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +VERSION=`git rev-parse --short HEAD` + +gentempl: + command -v templ &> /dev/null || go install github.com/a-h/templ/cmd/templ@latest +esbuild: + [ ! -f ./esbuild ] && curl -fsSL https://esbuild.github.io/dl/latest | sh +build:gentempl esbuild + templ generate + cat ./style/*.css | ./esbuild --loader=css --minify > ./static/style.css + go build -ldflags="-X 'github.com/rramiachraf/dumb/data.Version=$(VERSION)' -s -w" +test: + go test ./... -v +fmt: + templ fmt . diff --git a/README.md b/README.md index b55e2eb..5b45c57 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,44 @@ # dumb -With the massive daily increase of useless scripts on Genius's web frontend and having to download megabytes of clutter, [dumb](https://github.com/rramiachraf/dumb) tries to make reading lyrics from Genius a pleasant experience and as lightweight as possible. +With the massive daily increase of useless scripts on Genius's web frontend, and having to download megabytes of clutter, [dumb](https://github.com/rramiachraf/dumb) tries to make reading lyrics from Genius a pleasant experience, and as lightweight as possible. ![Screenshot](https://raw.githubusercontent.com/rramiachraf/dumb/main/screenshot.png) ## Installation & Usage -[Go 1.21+](https://go.dev/dl) is required. +### Docker +```bash +docker run -p 8080:5555 --name dumb ghcr.io/rramiachraf/dumb:latest +``` + +### Build from source +[Go 1.22+](https://go.dev/dl) is required. ```bash git clone https://github.com/rramiachraf/dumb cd dumb -go build +make build ./dumb ``` -The default port is 5555, you can use other ports by setting the `PORT` environment variable. +#### Notes: +- The default port is 5555, you can use other ports by setting the `PORT` environment variable. +- Genius servers are behind a Cloudflare reverse proxy, which means certain IPs won't be able to send requests, to partially mitigate this, you can specify a proxy by setting the `PROXY` variable (must be a valid URI). ## Public Instances +| URL | Tor | I2P | Region | CDN? | Operator +| --- | --- | --- | --- | --- | --- +| | No | No | NL | No | https://ducks.party +| | [Yes](http://dm.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion) | [Yes](http://vernxpcpqi2y4uhu7to4rnjmyjjgzh3x3qxyzpmkhykefchkmleq.b32.i2p) | US | No | https://vern.cc +| | No | No | DE | Yes | @MaximilianGT500 +| | [Yes](http://dumb.g4c3eya4clenolymqbpgwz3q3tawoxw56yhzk4vugqrl6dtu3ejvhjid.onion) | No | FR | No | https://privacydev.net +| | No | No | DE | - | https://privacyfucking.rocks | +| | No | No | US/DE | Yes | Whatever Social -| URL | Region | CDN? | Operator | -| --- | --- | --- | --- | -| | US | No | https://vern.cc | -| | US/DE | Yes | Whatever Social | -| | DE | Yes | @MaximilianGT500 | -| | FR | No | https://privacydev.net | -| | NL | No | https://ducks.party | +[Status Page](https://github.com/rramiachraf/dumb-instances) -### Tor -| URL | Operator | -| --- | --- | -| | https://vern.cc | -| | https://privacydev.net | - -### I2P -| URL | Operator | -| --- | --- | -| | https://vern.cc | - -For people who might be capable and interested in hosting a public instance feel free to do so and don't forget to open a pull request so your instance can be included here. +#### Notes: +- Instances list in JSON format can be found in [instances.json](instances.json) file. +- For people who might be capable and interested in hosting a public instance feel free to do so, and don't forget to open a pull request, so your instance can be included here. ## Contributing Contributions are welcome. diff --git a/data/album.go b/data/album.go new file mode 100644 index 0000000..085fd97 --- /dev/null +++ b/data/album.go @@ -0,0 +1,86 @@ +package data + +import ( + "encoding/json" + "strings" + + "github.com/PuerkitoBio/goquery" + "github.com/rramiachraf/dumb/utils" +) + +type AlbumPreview struct { + Name string + Image string + URL string +} + +type Album struct { + AlbumPreview + Artist ArtistPreview + About string + + Tracks []Track +} + +type Track struct { + Title string + Url string + Number int +} + +type albumMetadata struct { + Album struct { + Id int `json:"id"` + Image string `json:"cover_art_thumbnail_url"` + Name string `json:"name"` + Description string `json:"description_preview"` + artistPreviewMetadata `json:"artist"` + } + AlbumAppearances []AlbumAppearances `json:"album_appearances"` +} + +type AlbumAppearances struct { + Id int `json:"id"` + TrackNumber int `json:"track_number"` + Song struct { + Title string `json:"title"` + Url string `json:"url"` + } +} + +type artistPreviewMetadata struct { + Name string `json:"name"` + URL string `json:"url"` +} + +func (a *Album) parseAlbumData(doc *goquery.Document) error { + pageMetadata, exists := doc.Find("meta[itemprop='page_data']").Attr("content") + if !exists { + return nil + } + + var albumMetadataFromPage albumMetadata + if err := json.Unmarshal([]byte(pageMetadata), &albumMetadataFromPage); err != nil { + return err + } + + albumData := albumMetadataFromPage.Album + a.Artist = ArtistPreview{ + Name: albumData.artistPreviewMetadata.Name, + URL: utils.TrimURL(albumData.artistPreviewMetadata.URL), + } + a.Name = albumData.Name + a.Image = albumData.Image + a.About = albumData.Description + + for _, track := range albumMetadataFromPage.AlbumAppearances { + url := strings.Replace(track.Song.Url, "https://genius.com", "", -1) + a.Tracks = append(a.Tracks, Track{Title: track.Song.Title, Url: url, Number: track.TrackNumber}) + } + + return nil +} + +func (a *Album) Parse(doc *goquery.Document) error { + return a.parseAlbumData(doc) +} diff --git a/data/annotation.go b/data/annotation.go new file mode 100644 index 0000000..9e8bd66 --- /dev/null +++ b/data/annotation.go @@ -0,0 +1,15 @@ +package data + +type AnnotationsResponse struct { + Response struct { + Referent struct { + Annotations []struct { + Body Annotation `json:"body"` + } `json:"annotations"` + } `json:"referent"` + } `json:"response"` +} + +type Annotation struct { + HTML string `json:"html"` +} diff --git a/data/article.go b/data/article.go new file mode 100644 index 0000000..39e7c51 --- /dev/null +++ b/data/article.go @@ -0,0 +1,64 @@ +package data + +import ( + "encoding/json" + "time" + + "github.com/PuerkitoBio/goquery" + "github.com/rramiachraf/dumb/utils" +) + +type Article struct { + Title string + Subtitle string + HTML string + Authors []Author + PublishedAt time.Time + Image string +} + +type Author struct { + Name string + Role string `json:"human_readable_role_for_display"` + About string `json:"about_me_summary"` +} + +type articleResponse struct { + Article struct { + Title string + Subtitle string `json:"dek"` + Authors []Author + Body struct { + HTML string + } + PublishedAt int64 `json:"published_at"` + Image string `json:"preview_image"` + } +} + +func (a *Article) parseArticleData(doc *goquery.Document) error { + pageMetadata, exists := doc.Find("meta[itemprop='page_data']").Attr("content") + if !exists { + return nil + } + + var articleData articleResponse + if err := json.Unmarshal([]byte(pageMetadata), &articleData); err != nil { + return err + } + data := articleData.Article + + a.Title = data.Title + a.Subtitle = data.Subtitle + + a.HTML = utils.CleanBody(data.Body.HTML) + a.Authors = data.Authors + a.PublishedAt = time.Unix(data.PublishedAt, 0) + a.Image = ExtractImageURL(data.Image) + + return nil +} + +func (a *Article) Parse(doc *goquery.Document) error { + return a.parseArticleData(doc) +} diff --git a/data/artist.go b/data/artist.go new file mode 100644 index 0000000..a2b9162 --- /dev/null +++ b/data/artist.go @@ -0,0 +1,65 @@ +package data + +import ( + "encoding/json" + + "github.com/PuerkitoBio/goquery" + "github.com/rramiachraf/dumb/utils" +) + +type ArtistPreview struct { + Name string + URL string +} + +type Artist struct { + Name string + Description string + Albums []AlbumPreview + Image string +} + +type artistMetadata struct { + Artist struct { + Id int `json:"id"` + Name string `json:"name"` + Description string `json:"description_preview"` + Image string `json:"image_url"` + } + Albums []struct { + Id int `json:"id"` + Image string `json:"cover_art_thumbnail_url"` + Name string `json:"name"` + URL string `json:"url"` + } `json:"artist_albums"` +} + +func (a *Artist) parseArtistData(doc *goquery.Document) error { + pageMetadata, exists := doc.Find("meta[itemprop='page_data']").Attr("content") + if !exists { + return nil + } + + var artistMetadataFromPage artistMetadata + if err := json.Unmarshal([]byte(pageMetadata), &artistMetadataFromPage); err != nil { + return err + } + + a.Name = artistMetadataFromPage.Artist.Name + a.Description = artistMetadataFromPage.Artist.Description + a.Image = artistMetadataFromPage.Artist.Image + + for _, album := range artistMetadataFromPage.Albums { + a.Albums = append(a.Albums, AlbumPreview{ + Name: album.Name, + Image: album.Image, + URL: utils.TrimURL(album.URL), + }) + } + + return nil +} + +func (a *Artist) Parse(doc *goquery.Document) error { + return a.parseArtistData(doc) +} diff --git a/data/data.go b/data/data.go new file mode 100644 index 0000000..48d6f81 --- /dev/null +++ b/data/data.go @@ -0,0 +1,3 @@ +package data + +var Version = "DEVELOPMENT" diff --git a/data/lyrics.go b/data/lyrics.go new file mode 100644 index 0000000..bb1dc39 --- /dev/null +++ b/data/lyrics.go @@ -0,0 +1,139 @@ +package data + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/PuerkitoBio/goquery" + "github.com/rramiachraf/dumb/utils" +) + +type Song struct { + Artist string + Title string + Image string + Lyrics string + Credits map[string]string + About string + Album AlbumPreview + ArtistPageURL string +} + +type songResponse struct { + Response struct { + Song struct { + ArtistNames string `json:"artist_names"` + Image string `json:"song_art_image_thumbnail_url"` + Title string + Description struct { + Plain string + } + Album struct { + URL string `json:"url"` + Name string `json:"name"` + Image string `json:"cover_art_url"` + } + CustomPerformances []customPerformance `json:"custom_performances"` + WriterArtists []struct { + Name string + } `json:"writer_artists"` + ProducerArtists []struct { + Name string + } `json:"producer_artists"` + PrimaryArtist struct { + URL string + } `json:"primary_artist"` + } + } +} + +type customPerformance struct { + Label string + Artists []struct { + Name string + } +} + +func (s *Song) parseLyrics(doc *goquery.Document) error { + var htmlError error + + doc.Find("[data-lyrics-container='true']").Each(func(i int, ss *goquery.Selection) { + h, err := ss.Html() + if err != nil { + htmlError = err + } + s.Lyrics += h + }) + + if htmlError != nil { + return htmlError + } + + return nil +} + +func (s *Song) parseSongData(doc *goquery.Document) error { + attr, exists := doc.Find("meta[property='twitter:app:url:iphone']").Attr("content") + if exists { + songID := strings.Replace(attr, "genius://songs/", "", 1) + + u := fmt.Sprintf("https://genius.com/api/songs/%s?text_format=plain", songID) + + res, err := utils.SendRequest(u) + if err != nil { + return err + } + + defer res.Body.Close() + + var data songResponse + decoder := json.NewDecoder(res.Body) + err = decoder.Decode(&data) + if err != nil { + return err + } + + songData := data.Response.Song + s.Artist = songData.ArtistNames + s.Image = songData.Image + s.Title = songData.Title + s.About = songData.Description.Plain + s.Credits = make(map[string]string) + s.Album.Name = songData.Album.Name + s.ArtistPageURL = utils.TrimURL(songData.PrimaryArtist.URL) + s.Album.URL = utils.TrimURL(songData.Album.URL) + s.Album.Image = ExtractImageURL(songData.Album.Image) + + s.Credits["Writers"] = joinNames(songData.WriterArtists) + s.Credits["Producers"] = joinNames(songData.ProducerArtists) + for _, perf := range songData.CustomPerformances { + s.Credits[perf.Label] = joinNames(perf.Artists) + } + } + + return nil +} + +func joinNames(data []struct { + Name string +}, +) string { + var names []string + for _, hasName := range data { + names = append(names, hasName.Name) + } + return strings.Join(names, ", ") +} + +func (s *Song) Parse(doc *goquery.Document) error { + if err := s.parseLyrics(doc); err != nil { + return err + } + + if err := s.parseSongData(doc); err != nil { + return err + } + + return nil +} diff --git a/data/proxy.go b/data/proxy.go new file mode 100644 index 0000000..4b432d8 --- /dev/null +++ b/data/proxy.go @@ -0,0 +1,16 @@ +package data + +import ( + "fmt" + "net/url" +) + +func ExtractImageURL(image string) string { + u, err := url.Parse(image) + if err != nil { + return "" + } + + return fmt.Sprintf("/images%s", u.Path) +} + diff --git a/data/search.go b/data/search.go new file mode 100644 index 0000000..8490941 --- /dev/null +++ b/data/search.go @@ -0,0 +1,33 @@ +package data + +type SearchResponse struct { + Response struct { + Sections sections + } +} + +type result struct { + ArtistNames string `json:"artist_names"` + Title string + Path string + Thumbnail string `json:"song_art_image_thumbnail_url"` + ArtistImage string `json:"image_url"` + ArtistName string `json:"name"` + URL string `json:"url"` + AlbumImage string `json:"cover_art_url"` + AlbumName string `json:"full_title"` +} + +type hits []struct { + Result result +} + +type sections []struct { + Type string + Hits hits +} + +type SearchResults struct { + Query string + Sections sections +} diff --git a/go.mod b/go.mod index 63c93d0..32d18c8 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,17 @@ module github.com/rramiachraf/dumb -go 1.21 +go 1.22.2 require ( - github.com/PuerkitoBio/goquery v1.8.1 + github.com/PuerkitoBio/goquery v1.9.2 + github.com/a-h/templ v0.2.747 github.com/allegro/bigcache/v3 v3.1.0 - github.com/caffix/cloudflare-roundtripper v0.0.0-20181218223503-4c29d231c9cb + github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 - github.com/sirupsen/logrus v1.9.3 ) require ( github.com/andybalholm/cascadia v1.3.2 // indirect - github.com/robertkrimen/otto v0.3.0 // indirect - golang.org/x/net v0.20.0 // indirect - golang.org/x/sys v0.16.0 // indirect - golang.org/x/text v0.14.0 // indirect - gopkg.in/sourcemap.v1 v1.0.5 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + golang.org/x/net v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index e5e2388..2bf8f1d 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,19 @@ -github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= -github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= +github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= +github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= +github.com/a-h/templ v0.2.747 h1:D0dQ2lxC3W7Dxl6fxQ/1zZHBQslSkTSvl5FxP/CfdKg= +github.com/a-h/templ v0.2.747/go.mod h1:69ObQIbrcuwPCU32ohNaWce3Cb7qM5GMiqN1K+2yop4= github.com/allegro/bigcache/v3 v3.1.0 h1:H2Vp8VOvxcrB91o86fUSVJFqeuz8kpyyB02eH3bSzwk= github.com/allegro/bigcache/v3 v3.1.0/go.mod h1:aPyh7jEvrog9zAwx5N7+JUQX5dZTSGpxF1LAR4dr35I= -github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= -github.com/caffix/cloudflare-roundtripper v0.0.0-20181218223503-4c29d231c9cb h1:RKySaWkjoE/ECY1FFk4JbcfG9dTrJmoqp08rQ2oA51Y= -github.com/caffix/cloudflare-roundtripper v0.0.0-20181218223503-4c29d231c9cb/go.mod h1:LkIRP8n1KY5Ew4Y7S+V7ooavIXNrraFZ1IKmI4SNMuE= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/robertkrimen/otto v0.3.0 h1:5RI+8860NSxvXywDY9ddF5HcPw0puRsd8EgbXV0oqRE= -github.com/robertkrimen/otto v0.3.0/go.mod h1:uW9yN1CYflmUQYvAMS0m+ZiNo3dMzRUDQJX0jWbzgxw= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -29,47 +21,33 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= -gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= + diff --git a/handlers/album.go b/handlers/album.go new file mode 100644 index 0000000..8aa05d2 --- /dev/null +++ b/handlers/album.go @@ -0,0 +1,71 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + + "github.com/PuerkitoBio/goquery" + "github.com/gorilla/mux" + "github.com/rramiachraf/dumb/data" + "github.com/rramiachraf/dumb/utils" + "github.com/rramiachraf/dumb/views" +) + +func album(l *utils.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + artist := mux.Vars(r)["artist"] + albumName := mux.Vars(r)["albumName"] + + id := fmt.Sprintf("%s/%s", artist, albumName) + + if a, err := getCache[data.Album](id); err == nil { + views.AlbumPage(a).Render(context.Background(), w) + return + } + + url := fmt.Sprintf("https://genius.com/albums/%s/%s", artist, albumName) + + resp, err := utils.SendRequest(url) + if err != nil { + l.Error(err.Error()) + w.WriteHeader(http.StatusInternalServerError) + views.ErrorPage(500, "cannot reach Genius servers").Render(context.Background(), w) + return + } + + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + w.WriteHeader(http.StatusNotFound) + views.ErrorPage(404, "page not found").Render(context.Background(), w) + return + } + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + l.Error(err.Error()) + w.WriteHeader(http.StatusInternalServerError) + views.ErrorPage(500, "something went wrong").Render(context.Background(), w) + return + } + + cf := doc.Find(".cloudflare_content").Length() + if cf > 0 { + l.Error("cloudflare got in the way") + views.ErrorPage(500, "cloudflare is detected").Render(context.Background(), w) + return + } + + var a data.Album + if err = a.Parse(doc); err != nil { + l.Error(err.Error()) + } + + views.AlbumPage(a).Render(context.Background(), w) + + if err = setCache(id, a); err != nil { + l.Error(err.Error()) + } + } +} diff --git a/handlers/album_test.go b/handlers/album_test.go new file mode 100644 index 0000000..d106368 --- /dev/null +++ b/handlers/album_test.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/PuerkitoBio/goquery" + + "github.com/rramiachraf/dumb/utils" +) + +func TestAlbum(t *testing.T) { + url := "/albums/Daft-punk/Random-access-memories" + title := "Give Life Back to Music" + + r, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + l := utils.NewLogger(os.Stdout) + m := New(l, &assets{}) + + m.ServeHTTP(rr, r) + + defer rr.Result().Body.Close() + + doc, err := goquery.NewDocumentFromReader(rr.Result().Body) + if err != nil { + t.Fatal(err) + } + + docTitle := doc.Find("#album-tracklist > a > p").First().Text() + + if docTitle != title { + t.Fatalf("expected %q, got %q\n", title, docTitle) + } +} diff --git a/handlers/annotations.go b/handlers/annotations.go new file mode 100644 index 0000000..137bab3 --- /dev/null +++ b/handlers/annotations.go @@ -0,0 +1,81 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gorilla/mux" + "github.com/rramiachraf/dumb/data" + "github.com/rramiachraf/dumb/utils" + "github.com/rramiachraf/dumb/views" +) + +func annotations(l *utils.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["annotation-id"] + if a, err := getCache[data.Annotation]("annotation:" + id); err == nil { + encoder := json.NewEncoder(w) + + w.Header().Set("content-type", "application/json") + if err = encoder.Encode(&a); err != nil { + l.Error(err.Error()) + } + + return + } + + url := fmt.Sprintf("https://genius.com/api/referents/%s?text_format=html", id) + resp, err := utils.SendRequest(url) + + if err != nil { + l.Error(err.Error()) + w.WriteHeader(http.StatusInternalServerError) + views.ErrorPage(500, "cannot reach genius servers").Render(context.Background(), w) + return + } + + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + w.WriteHeader(http.StatusNotFound) + views.ErrorPage(404, "page not found").Render(context.Background(), w) + return + } + + buf := new(bytes.Buffer) + _, err = buf.ReadFrom(resp.Body) + if err != nil { + l.Error("Error paring genius api response: %s", err.Error()) + w.WriteHeader(http.StatusInternalServerError) + views.ErrorPage(500, "something went wrong").Render(context.Background(), w) + return + } + + var data data.AnnotationsResponse + err = json.Unmarshal(buf.Bytes(), &data) + if err != nil { + l.Error("could not unmarshal json: %s\n", err) + w.WriteHeader(http.StatusInternalServerError) + views.ErrorPage(500, "something went wrong").Render(context.Background(), w) + return + } + + body := data.Response.Referent.Annotations[0].Body + body.HTML = utils.CleanBody(body.HTML) + + w.Header().Set("content-type", "application/json") + encoder := json.NewEncoder(w) + + if err = encoder.Encode(&body); err != nil { + l.Error("Error sending response: %s", err.Error()) + return + } + + if err = setCache("annotation:"+id, body); err != nil { + l.Error(err.Error()) + } + } +} diff --git a/handlers/annotations_test.go b/handlers/annotations_test.go new file mode 100644 index 0000000..4a34d07 --- /dev/null +++ b/handlers/annotations_test.go @@ -0,0 +1,39 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/rramiachraf/dumb/utils" +) + +func TestAnnotations(t *testing.T) { + url := "/61590/Black-star-respiration/The-new-moon-rode-high-in-the-crown-of-the-metropolis/annotations" + + r, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + l := utils.NewLogger(os.Stdout) + m := New(l, &assets{}) + + m.ServeHTTP(rr, r) + + defer rr.Result().Body.Close() + + decoder := json.NewDecoder(rr.Result().Body) + annotation := map[string]string{} + + if err := decoder.Decode(&annotation); err != nil { + t.Fatal(err) + } + + if _, exists := annotation["html"]; !exists { + t.Fatalf("html field not found on annotation\n") + } +} diff --git a/handlers/article.go b/handlers/article.go new file mode 100644 index 0000000..08b5e22 --- /dev/null +++ b/handlers/article.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + + "github.com/PuerkitoBio/goquery" + "github.com/gorilla/mux" + "github.com/rramiachraf/dumb/data" + "github.com/rramiachraf/dumb/utils" + "github.com/rramiachraf/dumb/views" +) + +func article(l *utils.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + articleSlug := mux.Vars(r)["article"] + + if a, err := getCache[data.Article](articleSlug); err == nil { + views.ArticlePage(a).Render(context.Background(), w) + return + } + + url := fmt.Sprintf("https://genius.com/a/%s", articleSlug) + + resp, err := utils.SendRequest(url) + if err != nil { + l.Error(err.Error()) + w.WriteHeader(http.StatusInternalServerError) + views.ErrorPage(500, "cannot reach Genius servers").Render(context.Background(), w) + return + } + + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + w.WriteHeader(http.StatusNotFound) + views.ErrorPage(404, "page not found").Render(context.Background(), w) + return + } + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + l.Error(err.Error()) + w.WriteHeader(http.StatusInternalServerError) + views.ErrorPage(500, "something went wrong").Render(context.Background(), w) + return + } + + cf := doc.Find(".cloudflare_content").Length() + if cf > 0 { + l.Error("cloudflare got in the way") + views.ErrorPage(500, "cloudflare is detected").Render(context.Background(), w) + return + } + + var a data.Article + if err = a.Parse(doc); err != nil { + l.Error(err.Error()) + } + + views.ArticlePage(a).Render(context.Background(), w) + + if err = setCache(articleSlug, a); err != nil { + l.Error(err.Error()) + } + } +} diff --git a/handlers/article_test.go b/handlers/article_test.go new file mode 100644 index 0000000..9b6d13f --- /dev/null +++ b/handlers/article_test.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/PuerkitoBio/goquery" + + "github.com/rramiachraf/dumb/utils" +) + +func TestArticle(t *testing.T) { + url := "/a/genius-celebrates-hip-hops-50th-anniversary-with-a-look-back-at-the-music-thats-defined-this-site" + title := "Genius Celebrates Hip-Hop’s 50th Anniversary With A Look Back At The Music That’s Defined This Site" + subtitle := "The first post in a yearlong look at the genre’s storied history." + + r, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + l := utils.NewLogger(os.Stdout) + m := New(l, &assets{}) + + m.ServeHTTP(rr, r) + + defer rr.Result().Body.Close() + + if rr.Result().StatusCode != http.StatusOK { + t.Fatalf("expected %d, got %d\n", http.StatusOK, rr.Result().StatusCode) + } + + doc, err := goquery.NewDocumentFromReader(rr.Result().Body) + if err != nil { + t.Fatal(err) + } + + articleTitle := doc.Find("#article-title").First().Text() + if articleTitle != title { + t.Fatalf("expected %q, got %q\n", title, articleTitle) + } + + articleSubtitle := doc.Find("#article-subtitle").First().Text() + if articleSubtitle != subtitle { + t.Fatalf("expected %q, got %q\n", subtitle, articleSubtitle) + } + + articleBody := doc.Find("#article-body").First().Text() + if len(articleBody) == 0 { + t.Fatal("missing article body\n") + } +} diff --git a/handlers/artist.go b/handlers/artist.go new file mode 100644 index 0000000..4406133 --- /dev/null +++ b/handlers/artist.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + + "github.com/PuerkitoBio/goquery" + "github.com/gorilla/mux" + "github.com/rramiachraf/dumb/data" + "github.com/rramiachraf/dumb/utils" + "github.com/rramiachraf/dumb/views" +) + +func artist(l *utils.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + artistName := mux.Vars(r)["artist"] + + id := fmt.Sprintf("artist:%s", artistName) + + if a, err := getCache[data.Artist](id); err == nil { + views.ArtistPage(a).Render(context.Background(), w) + return + } + + url := fmt.Sprintf("https://genius.com/artists/%s", artistName) + + resp, err := utils.SendRequest(url) + if err != nil { + l.Error(err.Error()) + w.WriteHeader(http.StatusInternalServerError) + views.ErrorPage(500, "cannot reach Genius servers").Render(context.Background(), w) + return + } + + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + w.WriteHeader(http.StatusNotFound) + views.ErrorPage(404, "page not found").Render(context.Background(), w) + return + } + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + l.Error(err.Error()) + w.WriteHeader(http.StatusInternalServerError) + views.ErrorPage(500, "something went wrong").Render(context.Background(), w) + return + } + + cf := doc.Find(".cloudflare_content").Length() + if cf > 0 { + l.Error("cloudflare got in the way") + views.ErrorPage(500, "cloudflare is detected").Render(context.Background(), w) + return + } + + var a data.Artist + if err = a.Parse(doc); err != nil { + l.Error(err.Error()) + } + + views.ArtistPage(a).Render(context.Background(), w) + + if err = setCache(id, a); err != nil { + l.Error(err.Error()) + } + } +} diff --git a/handlers/artist_test.go b/handlers/artist_test.go new file mode 100644 index 0000000..b51ca32 --- /dev/null +++ b/handlers/artist_test.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/PuerkitoBio/goquery" + + "github.com/rramiachraf/dumb/utils" +) + +func TestArtist(t *testing.T) { + url := "/artists/Red-hot-chili-peppers" + name := "Red Hot Chili Peppers" + firstAlbumName := "Cardiff, Wales: 6/23/04" + + r, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + l := utils.NewLogger(os.Stdout) + m := New(l, &assets{}) + + m.ServeHTTP(rr, r) + + defer rr.Result().Body.Close() + + if rr.Result().StatusCode != http.StatusOK { + t.Fatalf("expected %d, got %d\n", http.StatusOK, rr.Result().StatusCode) + } + + doc, err := goquery.NewDocumentFromReader(rr.Result().Body) + if err != nil { + t.Fatal(err) + } + + artistName := doc.Find("#metadata-info > h1").First().Text() + if artistName != name { + t.Fatalf("expected %q, got %q\n", name, artistName) + } + + albumName := doc.Find("#artist-albumlist > a > p").First().Text() + if albumName != firstAlbumName { + t.Fatalf("expected %q, got %q\n", firstAlbumName, albumName) + } +} diff --git a/handlers/cache.go b/handlers/cache.go new file mode 100644 index 0000000..9ceab9e --- /dev/null +++ b/handlers/cache.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "context" + "encoding/json" + "time" + + "github.com/allegro/bigcache/v3" + "github.com/rramiachraf/dumb/data" +) + +type cachable interface { + data.Album | data.Song | data.Annotation | data.Artist | data.Article | []byte +} + +var c, _ = bigcache.New(context.Background(), bigcache.DefaultConfig(time.Hour*24)) + +func setCache(key string, entry interface{}) error { + data, err := json.Marshal(&entry) + if err != nil { + return err + } + + return c.Set(key, data) +} + +func getCache[v cachable](key string) (v, error) { + var decoded v + + data, err := c.Get(key) + if err != nil { + return decoded, err + } + + if err = json.Unmarshal(data, &decoded); err != nil { + return decoded, err + } + + return decoded, nil +} diff --git a/handlers/cache_test.go b/handlers/cache_test.go new file mode 100644 index 0000000..3a7b1b5 --- /dev/null +++ b/handlers/cache_test.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "bytes" + "testing" +) + +func TestCache(t *testing.T) { + key := "testkey" + value := []byte("testvalue") + + err := setCache(key, value) + if err != nil { + t.Fatalf("unable to set cache, %q\n", err) + } + + v, err := getCache[[]byte](key) + if err != nil { + t.Fatalf("unable to get cache, %q\n", err) + } + + if !bytes.Equal(v, value) { + t.Fatalf("expected %q, got %q\n", value, v) + } +} diff --git a/handlers/handler.go b/handlers/handler.go new file mode 100644 index 0000000..62e0f57 --- /dev/null +++ b/handlers/handler.go @@ -0,0 +1,76 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/a-h/templ" + gorillaHandlers "github.com/gorilla/handlers" + "github.com/gorilla/mux" + "github.com/rramiachraf/dumb/utils" + "github.com/rramiachraf/dumb/views" +) + +type route struct { + Path string + Handler func(*utils.Logger) http.HandlerFunc + Method string + Template func() templ.Component +} + +func New(logger *utils.Logger, staticFiles static) *mux.Router { + r := mux.NewRouter() + + r.Use(utils.MustHeaders) + r.Use(gorillaHandlers.CompressHandler) + + routes := []route{ + {Path: "/", Template: views.HomePage}, + {Path: "/robots.txt", Handler: robotsHandler}, + {Path: "/albums/{artist}/{albumName}", Handler: album}, + {Path: "/artists/{artist}", Handler: artist}, + {Path: "/a/{article}", Handler: article}, + {Path: "/images/{filename}.{ext}", Handler: imageProxy}, + {Path: "/search", Handler: search}, + {Path: "/{annotation-id}/{artist-song}/{verse}/annotations", Handler: annotations}, + {Path: "/instances.json", Handler: instances}, + } + + registerRoutes(r, routes, logger) + + r.PathPrefix("/static/").HandlerFunc(staticAssets(logger, staticFiles)) + r.PathPrefix("/{annotation-id}/{artist-song}-lyrics").HandlerFunc(lyrics(logger)).Methods("GET") + r.PathPrefix("/{annotation-id}/{artist-song}").HandlerFunc(lyrics(logger)).Methods("GET") + r.PathPrefix("/{annotation-id}").HandlerFunc(lyrics(logger)).Methods("GET") + + r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + views.ErrorPage(404, "page not found").Render(context.Background(), w) + }) + + return r +} + +func robotsHandler(l *utils.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write([]byte("User-agent: *\nDisallow: /\n")); err != nil { + l.Error(err.Error()) + } + } +} + +func registerRoutes(router *mux.Router, routes []route, logger *utils.Logger) { + for _, r := range routes { + method := r.Method + if method == "" { + method = http.MethodGet + } + + if r.Template != nil { + router.Handle(r.Path, templ.Handler(r.Template())).Methods(method) + continue + } + + router.HandleFunc(r.Path, r.Handler(logger)).Methods(method) + } +} diff --git a/handlers/handler_test.go b/handlers/handler_test.go new file mode 100644 index 0000000..e30467a --- /dev/null +++ b/handlers/handler_test.go @@ -0,0 +1,13 @@ +package handlers + +import ( + "io/fs" + "os" + "path" +) + +type assets struct{} + +func (assets) Open(p string) (fs.File, error) { + return os.Open(path.Join("../", p)) +} diff --git a/handlers/instances.go b/handlers/instances.go new file mode 100644 index 0000000..7fab7fb --- /dev/null +++ b/handlers/instances.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "context" + "io" + "net/http" + + "github.com/rramiachraf/dumb/utils" + "github.com/rramiachraf/dumb/views" +) + +const ContentTypeJSON = "application/json" + +// TODO: move this to utils, so it can be used by other handlers. +func sendError(err error, status int, msg string, l *utils.Logger, w http.ResponseWriter) { + l.Error(err.Error()) + w.WriteHeader(status) + if err := views.ErrorPage(status, msg).Render(context.Background(), w); err != nil { + l.Error(err.Error()) + } +} + +func instances(l *utils.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if instances, err := getCache[[]byte]("instances"); err == nil { + w.Header().Set("content-type", ContentTypeJSON) + _, err = w.Write(instances) + if err == nil { + return + } + } + + res, err := utils.SendRequest("https://raw.githubusercontent.com/rramiachraf/dumb/main/instances.json") + if err != nil { + sendError(err, http.StatusInternalServerError, "something went wrong", l, w) + return + } + + defer res.Body.Close() + + instances, err := io.ReadAll(res.Body) + if err != nil { + sendError(err, http.StatusInternalServerError, "something went wrong", l, w) + return + } + + w.Header().Set("content-type", ContentTypeJSON) + if _, err = w.Write(instances); err != nil { + l.Error(err.Error()) + } + + if err = setCache("instances", instances); err != nil { + l.Error(err.Error()) + } + } +} diff --git a/handlers/instances_test.go b/handlers/instances_test.go new file mode 100644 index 0000000..c7ab927 --- /dev/null +++ b/handlers/instances_test.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/rramiachraf/dumb/utils" +) + +func TestInstancesList(t *testing.T) { + r, err := http.NewRequest(http.MethodGet, "/instances.json", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + l := utils.NewLogger(os.Stdout) + + m := New(l, &assets{}) + m.ServeHTTP(rr, r) + + c := rr.Result().Header.Get("content-type") + if c != ContentTypeJSON { + t.Fatalf("expected %q, got %q", ContentTypeJSON, c) + } + + defer rr.Result().Body.Close() + + d := json.NewDecoder(rr.Result().Body) + instances := []map[string]any{} + if err := d.Decode(&instances); err != nil { + t.Fatalf("unable to decode json from response, %q\n", err) + } + + if _, exists := instances[0]["clearnet"]; !exists { + t.Fatal("unable to get clearnet value from instances list") + } +} diff --git a/handlers/lyrics.go b/handlers/lyrics.go new file mode 100644 index 0000000..8322d58 --- /dev/null +++ b/handlers/lyrics.go @@ -0,0 +1,72 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + + "github.com/PuerkitoBio/goquery" + "github.com/gorilla/mux" + "github.com/rramiachraf/dumb/data" + "github.com/rramiachraf/dumb/utils" + "github.com/rramiachraf/dumb/views" +) + +func lyrics(l *utils.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // prefer artist-song over annotation-id for cache key when available + id := mux.Vars(r)["artist-song"] + if id == "" { + id = mux.Vars(r)["annotation-id"] + } else { + id = id + "-lyrics" + } + + if s, err := getCache[data.Song](id); err == nil { + views.LyricsPage(s).Render(context.Background(), w) + return + } + + url := fmt.Sprintf("https://genius.com/%s", id) + resp, err := utils.SendRequest(url) + if err != nil { + l.Error(err.Error()) + w.WriteHeader(http.StatusInternalServerError) + views.ErrorPage(500, "cannot reach Genius servers").Render(context.Background(), w) + return + } + + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + w.WriteHeader(http.StatusNotFound) + views.ErrorPage(404, "page not found").Render(context.Background(), w) + return + } + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + l.Error(err.Error()) + w.WriteHeader(http.StatusInternalServerError) + views.ErrorPage(500, "something went wrong").Render(context.Background(), w) + return + } + + cf := doc.Find(".cloudflare_content").Length() + if cf > 0 { + l.Error("cloudflare got in the way") + views.ErrorPage(500, "cloudflare is detected").Render(context.Background(), w) + return + } + + var s data.Song + if err := s.Parse(doc); err != nil { + l.Error(err.Error()) + } + + views.LyricsPage(s).Render(context.Background(), w) + if err = setCache(id, s); err != nil { + l.Error(err.Error()) + } + } +} diff --git a/handlers/lyrics_test.go b/handlers/lyrics_test.go new file mode 100644 index 0000000..56db727 --- /dev/null +++ b/handlers/lyrics_test.go @@ -0,0 +1,57 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/PuerkitoBio/goquery" + "github.com/rramiachraf/dumb/utils" +) + +func TestLyrics(t *testing.T) { + urls := []string{"/The-silver-seas-catch-yer-own-train-lyrics", + "/1784308/The-silver-seas-catch-yer-own-train", + "/1784308/The-silver-seas-catch-yer-own-train-lyrics", + "/1784308/The-silver-seas-catch-yer-own-train/Baby-you-and-i-are-not-the-same-you-say-you-like-sun-i-like-the-rain", + "/1784308/The-silver-seas-catch-yer-own-train-lyrics/Baby-you-and-i-are-not-the-same-you-say-you-like-sun-i-like-the-rain", + "/1784308"} + for _, url := range urls { + t.Run(url, func(t *testing.T) { testLyrics(t, url) }) + } +} + +func testLyrics(t *testing.T, url string) { + title := "The Silver Seas" + artist := "Catch Yer Own Train" + + r, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + l := utils.NewLogger(os.Stdout) + m := New(l, &assets{}) + + m.ServeHTTP(rr, r) + + defer rr.Result().Body.Close() + + doc, err := goquery.NewDocumentFromReader(rr.Result().Body) + if err != nil { + t.Fatal(err) + } + + docArtist := doc.Find("#metadata-info h1").Text() + docTitle := doc.Find("#metadata-info h2").Text() + + if docTitle != title { + t.Fatalf("expected %q, got %q\n", title, docTitle) + } + + if docArtist != artist { + t.Fatalf("expected %q, got %q\n", artist, docArtist) + } +} diff --git a/handlers/proxy.go b/handlers/proxy.go new file mode 100644 index 0000000..b85ff82 --- /dev/null +++ b/handlers/proxy.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "context" + "fmt" + "io" + "mime" + "net/http" + "strings" + + "github.com/gorilla/mux" + "github.com/rramiachraf/dumb/utils" + "github.com/rramiachraf/dumb/views" +) + +func isValidExt(ext string) bool { + extType := mime.TypeByExtension("." + strings.ToLower(ext)) + isImage, _, found := strings.Cut(extType, "/") + if !found { + return false + } + + if isImage == "image" { + return true + } + + return false +} + +func imageProxy(l *utils.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + v := mux.Vars(r) + f := v["filename"] + ext := v["ext"] + + if !isValidExt(ext) { + w.WriteHeader(http.StatusBadRequest) + views.ErrorPage(400, "something went wrong").Render(context.Background(), w) + return + } + + // first segment of URL resize the image to reduce bandwith usage. + url := fmt.Sprintf("https://t2.genius.com/unsafe/300x300/https://images.genius.com/%s.%s", f, ext) + + res, err := utils.SendRequest(url) + if err != nil { + l.Error(err.Error()) + w.WriteHeader(http.StatusInternalServerError) + views.ErrorPage(500, "cannot reach Genius servers").Render(context.Background(), w) + return + } + + if res.StatusCode != http.StatusOK { + w.WriteHeader(http.StatusInternalServerError) + views.ErrorPage(500, "something went wrong").Render(context.Background(), w) + return + } + + w.Header().Set("Content-type", mime.TypeByExtension("."+ext)) + w.Header().Add("Cache-Control", "max-age=1296000") + if _, err = io.Copy(w, res.Body); err != nil { + l.Error("unable to write image, %s", err.Error()) + } + } +} diff --git a/handlers/proxy_test.go b/handlers/proxy_test.go new file mode 100644 index 0000000..2c712d6 --- /dev/null +++ b/handlers/proxy_test.go @@ -0,0 +1,39 @@ +package handlers + +import ( + "mime" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/rramiachraf/dumb/utils" +) + +func TestImageProxy(t *testing.T) { + imgURL := "/images/3852401ae6c6d6a51aafe814d67199f0.1000x1000x1.jpg" + + r, err := http.NewRequest(http.MethodGet, imgURL, nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + l := utils.NewLogger(os.Stdout) + m := New(l, &assets{}) + + m.ServeHTTP(rr, r) + + cc := rr.Result().Header.Get("cache-control") + maxAge := "max-age=1296000" + ct := rr.Result().Header.Get("content-type") + mimeType := mime.TypeByExtension(".jpg") + + if cc != maxAge { + t.Fatalf("expected %q, got %q\n", maxAge, cc) + } + + if ct != mimeType { + t.Fatalf("expected %q, got %q\n", mimeType, ct) + } +} diff --git a/handlers/search.go b/handlers/search.go new file mode 100644 index 0000000..c662b04 --- /dev/null +++ b/handlers/search.go @@ -0,0 +1,44 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/rramiachraf/dumb/data" + "github.com/rramiachraf/dumb/utils" + "github.com/rramiachraf/dumb/views" +) + +func search(l *utils.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + url := fmt.Sprintf(`https://genius.com/api/search/multi?q=%s`, url.QueryEscape(query)) + + res, err := utils.SendRequest(url) + if err != nil { + l.Error(err.Error()) + w.WriteHeader(http.StatusInternalServerError) + views.ErrorPage(500, "cannot reach Genius servers").Render(context.Background(), w) + return + } + + defer res.Body.Close() + + var sRes data.SearchResponse + + d := json.NewDecoder(res.Body) + if err = d.Decode(&sRes); err != nil { + l.Error(err.Error()) + w.WriteHeader(http.StatusInternalServerError) + views.ErrorPage(500, "something went wrong").Render(context.Background(), w) + } + + results := data.SearchResults{Query: query, Sections: sRes.Response.Sections} + + views.SearchPage(results).Render(context.Background(), w) + } + +} diff --git a/handlers/search_test.go b/handlers/search_test.go new file mode 100644 index 0000000..240310d --- /dev/null +++ b/handlers/search_test.go @@ -0,0 +1,39 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/PuerkitoBio/goquery" + "github.com/rramiachraf/dumb/utils" +) + +func TestSearch(t *testing.T) { + url := "/search?q=it+aint+hard+to+tell" + artist := "Nas" + + r, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + l := utils.NewLogger(os.Stdout) + m := New(l, &assets{}) + + m.ServeHTTP(rr, r) + + defer rr.Result().Body.Close() + + doc, err := goquery.NewDocumentFromReader(rr.Result().Body) + if err != nil { + t.Fatal(err) + } + + docArtist := doc.Find("#search-item > div > span").First().Text() + if docArtist != artist { + t.Fatalf("expected %q, got %q\n", artist, docArtist) + } +} diff --git a/handlers/static.go b/handlers/static.go new file mode 100644 index 0000000..f420717 --- /dev/null +++ b/handlers/static.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "io" + "io/fs" + "mime" + "net/http" + "path" + "strings" + + "github.com/rramiachraf/dumb/utils" + "github.com/rramiachraf/dumb/views" +) + +type static interface { + Open(string) (fs.File, error) +} + +func staticAssets(logger *utils.Logger, embededFiles static) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + url := strings.Replace(r.URL.Path, "/static", "static", 1) + f, err := embededFiles.Open(url) + if err != nil { + w.WriteHeader(http.StatusNotFound) + views.ErrorPage(http.StatusNotFound, "page not found") + return + } + + defer f.Close() + + mimeType := mime.TypeByExtension(path.Ext(r.URL.Path)) + w.Header().Set("content-type", mimeType) + + if _, err := io.Copy(w, f); err != nil { + logger.Error(err.Error()) + } + } +} diff --git a/handlers/static_test.go b/handlers/static_test.go new file mode 100644 index 0000000..ecd6bd2 --- /dev/null +++ b/handlers/static_test.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "mime" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/rramiachraf/dumb/utils" +) + +func TestStaticAssets(t *testing.T) { + r, err := http.NewRequest(http.MethodGet, "/static/style.css", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + l := utils.NewLogger(os.Stdout) + m := New(l, &assets{}) + + m.ServeHTTP(rr, r) + + contentType := rr.Header().Get("content-type") + expectedContentType := mime.TypeByExtension(".css") + + if contentType != expectedContentType { + t.Fatalf("expected %q, got %q", expectedContentType, contentType) + } + + if rr.Code != 200 { + t.Fatalf("expected %d, got %d", 200, rr.Code) + } +} diff --git a/instances.json b/instances.json new file mode 100644 index 0000000..96c5e0b --- /dev/null +++ b/instances.json @@ -0,0 +1,35 @@ +[ + { + "clearnet": "https://dm.vern.cc/", + "tor": "http://dm.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion/", + "i2p": "http://vernxpcpqi2y4uhu7to4rnjmyjjgzh3x3qxyzpmkhykefchkmleq.b32.i2p/", + "country": "US", + "cdn": false + }, + { + "clearnet": "https://sing.whatever.social/", + "country": "US/DE", + "cdn": true + }, + { + "clearnet": "https://dumb.lunar.icu/", + "country": "DE", + "cdn": true + }, + { + "clearnet": "https://dumb.privacydev.net/", + "tor": "http://dumb.g4c3eya4clenolymqbpgwz3q3tawoxw56yhzk4vugqrl6dtu3ejvhjid.onion/", + "country": "FR", + "cdn": false + }, + { + "clearnet": "https://dumb.ducks.party/", + "country": "NL", + "cdn": false + }, + { + "clearnet": "https://dumb.privacyfucking.rocks/", + "country": "DE", + "cdn": false + } +] diff --git a/main.go b/main.go index 1dde722..760d907 100644 --- a/main.go +++ b/main.go @@ -1,22 +1,28 @@ package main import ( +<<<<<<< HEAD "context" +======= + "embed" +>>>>>>> upstream/main "fmt" "net" "net/http" + "net/url" "os" "strconv" "time" - "github.com/allegro/bigcache/v3" - "github.com/gorilla/mux" - "github.com/sirupsen/logrus" + "github.com/rramiachraf/dumb/handlers" + "github.com/rramiachraf/dumb/utils" ) -var logger = logrus.New() +//go:embed static +var staticFiles embed.FS func main() { +<<<<<<< HEAD ctx := context.Background() c, err := bigcache.New(ctx, bigcache.DefaultConfig(time.Hour*24)) if err != nil { @@ -43,25 +49,40 @@ func main() { }) }) +======= + logger := utils.NewLogger(os.Stdout) +>>>>>>> upstream/main server := &http.Server{ - Handler: r, + Handler: handlers.New(logger, staticFiles), WriteTimeout: 25 * time.Second, ReadTimeout: 25 * time.Second, } + PROXY_ENV := os.Getenv("PROXY") + if PROXY_ENV != "" { + if _, err := url.ParseRequestURI(PROXY_ENV); err != nil { + logger.Fatal("invalid proxy") + } + + logger.Info("using a custom proxy for requests") + } + port, _ := strconv.Atoi(os.Getenv("PORT")) if port == 0 { port = 5555 + logger.Info("using default port %d", port) } l, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) if err != nil { - logger.Fatalln(err) + logger.Fatal(err.Error()) } - logger.Infof("server is listening on port %d\n", port) + logger.Info("server is listening on port %d", port) - logger.Fatalln(server.Serve(l)) + if err := server.Serve(l); err != nil { + logger.Fatal(err.Error()) + } } diff --git a/screenshot.png b/screenshot.png index 675667e..168c657 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/scripts/dumb.service b/scripts/dumb.service deleted file mode 100644 index 09d242f..0000000 --- a/scripts/dumb.service +++ /dev/null @@ -1,21 +0,0 @@ -[Unit] -Description=ListMonk -Documentation=https://github.com/rramiachraf/dumb -After=system.slice multi-user.target postgresql.service network.target - -[Service] -User=git -Type=simple - -StandardOutput=syslog -StandardError=syslog -SyslogIdentifier=listmonk - -WorkingDirectory=/etc/dumb -ExecStart=/etc/dumb/dumb - -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target diff --git a/scripts/nginx-config b/scripts/nginx-config deleted file mode 100644 index 7cafb9d..0000000 --- a/scripts/nginx-config +++ /dev/null @@ -1,35 +0,0 @@ -server { - # root /var/www/dumb.yoursite.com/html; - # index index.html index.htm index.nginx-debian.html; - - server_name dumb.yoursite.com; - # www.dumb.yoursite.com; - - location / { - try_files $uri $uri/ =404; - proxy_pass http://localhost:5555; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - listen 443 ssl; # managed by Certbot - ssl_certificate /etc/letsencrypt/live/dumb.yoursite.com/fullchain.pem; # managed by Certbot - ssl_certificate_key /etc/letsencrypt/live/dumb.yoursite.com/privkey.pem; # managed by Certbot - include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot - -} -server { - if ($host = dumb.yoursite.com) { - return 301 https://$host$request_uri; - } # managed by Certbot - - server_name dumb.yoursite.com; - - listen 80; - return 404; # managed by Certbot - -} - diff --git a/static/script.js b/static/script.js index 327ae5a..49fa883 100644 --- a/static/script.js +++ b/static/script.js @@ -1,21 +1,34 @@ -const fullAbout = document.querySelector("#about #full_about") -const summary = document.querySelector("#about #summary") +const description = document.querySelector("#description > #full") +const summary = document.querySelector("#description > #summary") -function showAbout() { +function showDescription() { summary.classList.toggle("hidden") - fullAbout.classList.toggle("hidden") + description.classList.toggle("hidden") } -[fullAbout, summary].forEach(item => item.onclick = showAbout) +description && [description, summary].forEach(item => item.onclick = showDescription) -document.querySelectorAll("#lyrics a").forEach(item => { - item.addEventListener("click", getAnnotation) +window.addEventListener("load", () => { + const geniusURL = "https://genius.com" + document.location.pathname + document.location.search + document.getElementById("goto-genius").setAttribute("href", geniusURL) + document.querySelectorAll("#lyrics a").forEach(item => { + item.addEventListener("click", getAnnotation) + }) + + const linkedAnnotationId = window.location.pathname.match(new RegExp("/(\\d+)"))?.[1] + if (linkedAnnotationId) { + const target = document.querySelector(`a[href^="/${linkedAnnotationId}"][class^="ReferentFragmentdesktop__ClickTarget"] > span`) + target?.click() + target?.scrollIntoView() + } }) function getAnnotation(e) { e.preventDefault() - const uri = e.target.parentElement.getAttribute("href") - const presentAnnotation = document.getElementById(uri) + //document.querySelector('.annotation')?.remove() + const link = e.currentTarget + const uri = link.getAttribute("href") + const presentAnnotation = link.nextElementSibling.matches(".annotation") && link.nextElementSibling if (presentAnnotation) { presentAnnotation.remove() return @@ -31,7 +44,40 @@ function getAnnotation(e) { annotationDiv.innerHTML = parsedReponse.html annotationDiv.id = uri annotationDiv.className = "annotation" - e.target.parentElement.insertAdjacentElement('afterend', annotationDiv) + if (!link.nextElementSibling.matches(".annotation")) { + link.insertAdjacentElement('afterend', annotationDiv) + } } } } + +window._currentTheme = localStorage.getItem("_theme") || "light" +setTheme(window._currentTheme) + +const themeChooser = document.getElementById("choose-theme") +themeChooser.addEventListener("click", function() { + if (window._currentTheme === "dark") { + setTheme("light") + } else { + setTheme("dark") + } +}) + +function setTheme(theme) { + const toggler = document.getElementById("ic_fluent_dark_theme_24_regular") + switch (theme) { + case "dark": + toggler.setAttribute("fill", "#fff") + localStorage.setItem("_theme", "dark") + document.body.classList.add("dark") + window._currentTheme = "dark" + return + case "light": + toggler.setAttribute("fill", "#181d31") + localStorage.setItem("_theme", "light") + document.body.classList.remove("dark") + window._currentTheme = "light" + return + + } +} diff --git a/static/style.css b/static/style.css deleted file mode 100644 index 530484d..0000000 --- a/static/style.css +++ /dev/null @@ -1,406 +0,0 @@ -/* inter-regular - cyrillic_greek_latin_vietnamese */ -@font-face { - font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ - font-family: 'Inter'; - font-style: normal; - font-weight: 400; - src: url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-regular.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+ */ - url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-regular.woff') format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */ -} - -/* inter-500 - cyrillic_greek_latin_vietnamese */ -@font-face { - font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ - font-family: 'Inter'; - font-style: normal; - font-weight: 500; - src: url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-500.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+ */ - url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-500.woff') format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */ -} - -/* inter-700 - cyrillic_greek_latin_vietnamese */ -@font-face { - font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ - font-family: 'Inter'; - font-style: normal; - font-weight: 700; - src: url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-700.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+ */ - url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-700.woff') format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */ -} - -* { - margin: 0; - padding: 0; -} - -html { - font-size: 62.5%; -} - -body { - font-size: 1.5rem; - font-family: inter; - background-color: #f9f9f9; -} - -#lyrics { - color: #171717; - line-height: 2.5rem; - flex-basis: 0; - flex-shrink: 0; - flex-grow: 1; -} - -#lyrics a { - color: inherit; - cursor: initial; -} - -#lyrics a, .annotation { - background-color: #ddd; - color: inherit; -} - -#lyrics a:hover { - background-color: #ccc; -} - -nav { - background-color: #ffcd38; - display: flex; - align-items: center; - justify-content: center; - padding: 0.5rem; -} - -nav img { - width: 50px; -} - -a { - text-decoration: none; -} - -#metadata { - display: flex; - flex-direction: column; - gap: 0.5rem; - flex-basis: 0; -} - -#metadata h1 { - font-size: 2rem; - color: #171717; -} - -#metadata h2 { - font-size: 1.4rem; - color: #1e1e1e; - font-weight: 500; -} - -#album-artwork { - width: 20rem; - border-radius: 3px; - box-shadow: 0 1px 1px #ddd; - max-width: 200px; -} - -#container { - display: flex; - padding: 5rem 10rem; - gap: 5rem; -} - -#credits { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -#title { - font-size: 2rem; - color: #1b1a17; -} - -#credits summary { - font-size: 1.4rem; - cursor: pointer; - color: #1e1e1e; -} - -#album-tracklist{ - display: flex; - flex-direction: column; - gap: 1rem; - flex-basis: 0; - flex-shrink: 0; - flex-grow: 1; -} - -#credits p { - font-size: 1.3rem; - padding: 0.5rem; - color: #171717; -} - -#info { - display: flex; - flex-direction: column; - gap: 2rem; - flex-basis: 0; -} - -#about { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -#about p { - font-size: 1.4rem; - color: #171717; - line-height: 1.8rem; - cursor: pointer; -} - -.annotation { - padding: 1rem; -} - -.annotation img { - max-width: 100%; - height: auto; -} - -.annotation ul { - padding-left: 1em; -} - -.hidden { - display: none; -} - -#home { - display: flex; - flex-direction: column; - gap: 1.5rem; - align-items: center; - padding: 2rem; - flex-grow: 1; -} - -#home div { - text-align: center; -} - -#home h1 { - font-weight: 600; - font-size: 2.2rem; - color: #222; -} - -#home p { - color: #333; -} - -#home code { - background-color: #eee; - padding: 0.3rem 1rem; - border-radius: 0.5rem; - color: #333; -} - -@media screen and (max-width: 900px) { - #container { - padding: 3rem 2rem; - flex-direction: column; - gap: 3rem; - } - - #metadata { - align-items: center; - } -} - -footer { - text-align: center; - background-color: #ffcd38; - padding: 1rem; -} - -footer a { - font-weight: 500; - color: #1b1a17; - transition: 0.3s ease text-decoration; - font-size: 1.4rem; - text-transform: uppercase; -} - -footer a:hover { - text-decoration: underline; -} - -#app { - display: flex; - flex-direction: column; - min-height: 100vh; -} - -#error { - display: flex; - flex-direction: column; - gap: 1rem; - align-items: center; - justify-content: center; - padding: 2rem; - flex-grow: 1; -} - -#error h1 { - font-size: 5rem; - color: #111; -} - -#error p { - text-transform: uppercase; - font-size: 1.6rem; - color: #222; - text-align: center; -} - -.main { - flex-grow: 1; -} - -#search-page { - display: flex; - flex-direction: column; - gap: 3rem; - padding: 2rem 1rem; -} - -@media screen and (min-width: 800px) { - #search-page { - width: 80rem; - margin: 0 auto; - } -} - -#search-input { - width: 100%; - padding: 1rem 2rem; - box-sizing: border-box; - border-radius: 5px; - border: 1px solid #ddd; - color: #222; -} - -#search-results { - display: flex; - flex-direction: column; - gap: 1rem; -} - -#search-results h1 { - text-align:center; - color: #111; - font-size: 2.5rem; -} - -#search-item { - display: flex; - height: 8rem; - border: 1px solid #eee; - border-radius: 5px; - gap: 1rem; - padding: 1rem; - box-shadow: 0 1px 1px #ddd; -} - -#search-item h2 { - font-size: 1.8rem; - color: #222; -} - -#search-item span { - font-size: 1.3rem; - color: #333; -} - -#search-item img { - width: 8rem; - border-radius: 5px; -} - -/* dark mode */ -@media (prefers-color-scheme: dark) { - body { - background-color: #181d31; - } - - nav, - footer { - background-color: #fec260; - } - - #lyrics { - color: #ccc; - } - - #lyrics a, .annotation { - background-color: #272d44; - color: inherit; - } - - - #lyrics a:hover { - background-color: #32384f; - } - - #metadata h1 { - color: #ddd; - } - - #metadata h2, - #credits p { - color: #eee; - } - - #title { - color: #ddd; - } - - #about p, - #credits summary { - color: #ccc; - } - - #home h1, #error h1 { - color: #eee; - } - - #home p, #error p{ - color: #ddd; - } - - #search-input { - background-color: #ddd; - } - - #search-page h1 { - color: #eee; - } - - #search-item { - border: 1px solid #888; - } - - #search-item h2 { - color: #ddd; - } - - #search-item span { - color: #bbb; - } -} diff --git a/style/album.css b/style/album.css new file mode 100644 index 0000000..4c277d4 --- /dev/null +++ b/style/album.css @@ -0,0 +1,44 @@ +#album-artwork { + width: 24rem; + border-radius: 3px; + box-shadow: 0 1px 1px #ddd; +} + +#album-tracklist { + display: flex; + flex-direction: column; + gap: 1rem; + flex-basis: 0; + flex-shrink: 0; + flex-grow: 1; +} + +#album-single-track { + display: grid; + grid-template-columns: 3rem 1fr; + gap: 1rem; + align-items: center; + justify-content: center; +} + +#album-single-track p { + color: #181d31; + font-weight: 500; +} + +.dark #album-single-track p { + color: #ddd; +} + +#album-single-track small { + font-size: 1.5rem; + color: #333; +} + +.dark #album-single-track small { + color: #ccc; +} + +#album-single-track:hover p { + text-decoration: underline; +} diff --git a/style/annotation.css b/style/annotation.css new file mode 100644 index 0000000..1cb43a3 --- /dev/null +++ b/style/annotation.css @@ -0,0 +1,33 @@ +#iframed-link { + font-weight: 500; + background-color: #ffcd38; + padding: 2px 6px; +} + +.annotation, blockquote { + padding: 1rem; + border-radius: 4px; + background: #eee; + border: 1px solid #ddd; + color: #222; + margin: 1rem 0; +} + +.annotation img, blockquote img { + max-width: 100%; + height: auto; +} + +.annotation a, blockquote a { + background: none; + font-weight: 500; +} + +.annotation ul, blockquote ul { + padding-left: 1em; +} + +.dark .annotation, .dark blockquote { + background-color: #272d44; + color: inherit; +} diff --git a/style/article.css b/style/article.css new file mode 100644 index 0000000..0be76b9 --- /dev/null +++ b/style/article.css @@ -0,0 +1,60 @@ +#article-metadata { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 0.5rem; +} + +#article-body { + line-height: 1.75; + color: #171717; +} + +#article-subtitle { + font-size: 1.8rem; + text-align: center; +} + +#article-image { + width: 100%; + height: 50rem; + border-radius: 5px; + object-fit: contain; + object-position: center; + background-color: #f7f7f7; + border: 1px solid #e4e4e4; +} + +#metadata, +#article-subtitle, +#article-date { + color: #333; +} + +#article-authors { + color: #1e1e1e; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.dark #article-image { + background-color: #151515; + border: 1px solid #2f2f2f; +} + +.dark #metadata, +.dark #article-subtitle, +.dark #article-date, +.dark #article-authors { + color: #ccc; +} + +.dark #article-title { + color: #eee; +} + +.dark #article-body { + color: #eee; +} diff --git a/style/artist.css b/style/artist.css new file mode 100644 index 0000000..06f64b2 --- /dev/null +++ b/style/artist.css @@ -0,0 +1,72 @@ +#artist-albumlist { + color: #181d31; + font-weight: 500; + display: grid; + grid-template-columns: repeat(auto-fill, 150px); + gap: 1.5rem; +} + +#artwork-preview { + width: 150px; + height: 150px; + border-radius: 5px; + border: 1px solid #ddd; +} + +#artist-image { + border-radius: 50%; + border: 2px solid #ddd; +} + +#artist-name { + text-align: center; +} + +#artist-single-album { + color: #111; + display: flex; + flex-direction: column; + gap: 0.5rem; + text-align: center; +} + +.dark #artist-albumlist p { + color: #ddd; +} + +#artist-albumlist small { + font-size: 1.5rem; + color: #333; +} + +.dark #artist-albumlist small { + color: #ccc; +} + +#metadata p { + color: #171717; +} + +.dark #metadata p { + color: #ddd; +} + +#artist-section { + display: flex; + flex-direction: column; + gap: 1rem; +} + +#artist-sections { + display: flex; + flex-direction: column; + gap: 2rem; +} + +#artist-section h2 { + font-size: 2rem; +} + +.dark #artist-section h2 { + color: #ddd; +} diff --git a/style/error.css b/style/error.css new file mode 100644 index 0000000..95fff45 --- /dev/null +++ b/style/error.css @@ -0,0 +1,29 @@ +#error { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; + justify-content: center; + padding: 2rem; + flex-grow: 1; +} + +#error h1 { + font-size: 5rem; + color: #111; +} + +#error p { + text-transform: uppercase; + font-size: 1.6rem; + color: #222; + text-align: center; +} + +.dark #error h1 { + color: #eee; +} + +.dark #error p { + color: #ddd; +} diff --git a/style/footer.css b/style/footer.css new file mode 100644 index 0000000..80fbe81 --- /dev/null +++ b/style/footer.css @@ -0,0 +1,46 @@ +footer { + background-color: #ffcd38; + padding: 1rem 0; +} + +footer a { + font-weight: 500; + color: #1b1a17; + transition: 0.3s ease text-decoration; + font-size: 1.4rem; + text-transform: uppercase; +} + +footer a:hover { + text-decoration: underline; +} + +#footer-container { + width: 1024px; + display: flex; + justify-content: space-between; + align-items: center; + margin: 0 auto; +} + +#footer-links { + display: flex; + gap: 1rem; +} + +#version { + font-size: 1.3rem; + color: #1b1b1b; +} + +@media screen and (max-width: 1080px) { + #footer-container { + width: 100%; + padding: 0 2rem; + box-sizing: border-box; + } +} + +.dark footer { + background-color: #fec260; +} diff --git a/style/home.css b/style/home.css new file mode 100644 index 0000000..a766d32 --- /dev/null +++ b/style/home.css @@ -0,0 +1,50 @@ +#home { + display: flex; + flex-direction: column; + gap: 1.5rem; + align-items: center; + padding: 2rem; + flex-grow: 1; +} + +#home div { + text-align: center; +} + +#home h1 { + font-weight: 600; + font-size: 2.2rem; + color: #222; +} + +#home p { + color: #333; +} + +#home code { + background-color: #eee; + padding: 0.3rem 1rem; + border-radius: 0.5rem; + color: #333; +} + +#search-input { + width: 100%; + padding: 1rem 2rem; + box-sizing: border-box; + border-radius: 5px; + border: 1px solid #ddd; + color: #222; +} + +.dark #home h1 { + color: #eee; +} + +.dark #home p { + color: #ddd; +} + +.dark #search-input { + background-color: #ddd; +} diff --git a/style/layout.css b/style/layout.css new file mode 100644 index 0000000..2fe9bdd --- /dev/null +++ b/style/layout.css @@ -0,0 +1,42 @@ +#app { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +#container { + padding: 5rem 0; + width: 1024px; + margin: 0 auto; + display: grid; + flex: 1; +} + +.solo { + gap: 4rem; +} + +.trio-split { + grid-template-columns: 24rem calc(1024px - 56rem) 24rem; + gap: 4rem; +} + +.duo-split { + grid-template-columns: 24rem 1fr; + gap: 4rem; +} + +.main { + flex-grow: 1; +} + +@media screen and (max-width: 1080px) { + #container { + padding: 3rem 2rem; + display: flex; + flex-direction: column; + gap: 3rem; + width: calc(100vw - 4rem); + ; + } +} diff --git a/style/lyrics.css b/style/lyrics.css new file mode 100644 index 0000000..99db93a --- /dev/null +++ b/style/lyrics.css @@ -0,0 +1,134 @@ +#lyrics { + color: #171717; + line-height: 2.5rem; + flex-basis: 0; + flex-shrink: 0; + flex-grow: 1; +} + +#lyrics a { + color: inherit; + background-color: #ddd; + color: inherit; +} + +#lyrics a:hover { + background-color: #ccc; +} + +#metadata { + display: flex; + flex-direction: column; + gap: 2rem; + flex-basis: 0; +} + +#metadata h1 { + font-size: 2rem; + color: #171717; +} + +#metadata h2 { + font-size: 1.4rem; + color: #1e1e1e; + font-weight: 500; +} + +#metadata-info { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +#info { + display: flex; + flex-direction: column; + gap: 2rem; + flex-basis: 0; +} + +#description { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +#description p { + font-size: 1.4rem; + color: #171717; + line-height: 1.8rem; + cursor: pointer; +} + +#title { + font-size: 2rem; + color: #1b1a17; +} + + +#lyrics-album-container { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +#credits { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + + +#credits summary { + font-size: 1.4rem; + cursor: pointer; + color: #1e1e1e; +} + +#credits p { + font-size: 1.3rem; + padding: 0.5rem; + color: #171717; +} + +.hidden { + display: none; +} + +.dark #lyrics { + color: #ccc; +} + +.dark #lyrics a { + background-color: #272d44; + color: inherit; +} + +.dark #lyrics a:hover { + background-color: #32384f; +} + +.dark #metadata h1 { + color: #ddd; +} + +.dark #metadata h2, +.dark #credits p { + color: #eee; +} + +.dark #title { + color: #ddd; +} + +.dark #description p, +.dark #credits summary { + color: #ccc; +} + +@media screen and (max-width: 1080px) { + #metadata { + align-items: center; + text-align: center; + } +} diff --git a/style/main.css b/style/main.css new file mode 100644 index 0000000..5b94c27 --- /dev/null +++ b/style/main.css @@ -0,0 +1,66 @@ +/* inter-regular - cyrillic_greek_latin_vietnamese */ +@font-face { + font-display: swap; + /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + src: url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-regular.woff2') format('woff2'), + /* Chrome 36+, Opera 23+, Firefox 39+ */ + url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-regular.woff') format('woff'); + /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} + +/* inter-500 - cyrillic_greek_latin_vietnamese */ +@font-face { + font-display: swap; + /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + src: url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-500.woff2') format('woff2'), + /* Chrome 36+, Opera 23+, Firefox 39+ */ + url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-500.woff') format('woff'); + /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} + +/* inter-700 - cyrillic_greek_latin_vietnamese */ +@font-face { + font-display: swap; + /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + src: url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-700.woff2') format('woff2'), + /* Chrome 36+, Opera 23+, Firefox 39+ */ + url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-700.woff') format('woff'); + /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} + +* { + margin: 0; + padding: 0; +} + +html { + font-size: 62.5%; +} + +body { + font-size: 1.5rem; + font-family: inter; + background-color: #f9f9f9; +} + + +a { + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +body.dark { + background-color: #181d31; +} diff --git a/style/navbar.css b/style/navbar.css new file mode 100644 index 0000000..8efece6 --- /dev/null +++ b/style/navbar.css @@ -0,0 +1,44 @@ +nav { + background-color: #ffcd38; + padding: 1rem 0; +} + +#nav-container { + width: 1024px; + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 auto; +} + +@media screen and (max-width: 1080px) { + #nav-container { + width: 100%; + padding: 0 2rem; + box-sizing: border-box; + } +} + + +nav img { + width: 50px; +} + +#nav-icons { + display: flex; + align-items: center; + gap: 1rem; +} + +.nav-icon { + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.dark nav { + background-color: #fec260; +} diff --git a/style/search.css b/style/search.css new file mode 100644 index 0000000..70fd4c6 --- /dev/null +++ b/style/search.css @@ -0,0 +1,81 @@ +#search-page { + display: flex; + flex-direction: column; + gap: 3rem; + padding: 2rem 1rem; +} + +@media screen and (min-width: 800px) { + #search-page { + width: 80rem; + margin: 0 auto; + } +} + +#search-results { + display: flex; + flex-direction: column; + gap: 4rem; +} + +#search-results h2 { + color: #222; + font-size: 1.8rem; + font-weight: 500; +} + +#search-section { + display: flex; + flex-direction: column; + gap: 1rem; +} + +#search-item { + display: flex; + height: 8rem; + border: 1px solid #eee; + border-radius: 5px; + gap: 1rem; + padding: 1rem; + box-shadow: 0 1px 1px #ddd; +} + +#search-item h3 { + font-size: 1.8rem; + color: #333; +} + +#search-item span { + font-size: 1.3rem; + color: #333; +} + +#search-item img { + width: 8rem; + border-radius: 5px; +} + +#search-input { + width: 100%; + padding: 1rem 2rem; + box-sizing: border-box; + border-radius: 5px; + border: 1px solid #ddd; + color: #222; +} + +.dark #search-page h2 { + color: #eee; +} + +.dark #search-item { + border: 1px solid #888; +} + +.dark #search-item h3 { + color: #ddd; +} + +.dark #search-item span { + color: #bbb; +} diff --git a/utils.go b/utils.go deleted file mode 100644 index a7ccc8a..0000000 --- a/utils.go +++ /dev/null @@ -1,122 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "net" - "net/http" - "net/url" - "path" - "text/template" - "time" - - "github.com/allegro/bigcache/v3" - "github.com/caffix/cloudflare-roundtripper/cfrt" -) - -var cache *bigcache.BigCache - -func setCache(key string, entry interface{}) error { - data, err := json.Marshal(&entry) - if err != nil { - return err - } - - return cache.Set(key, data) -} - -func getCache(key string) (interface{}, error) { - data, err := cache.Get(key) - if err != nil { - return nil, err - } - - var decoded interface{} - - if err = json.Unmarshal(data, &decoded); err != nil { - return nil, err - } - - return decoded, nil -} - -func write(w http.ResponseWriter, status int, data []byte) { - w.WriteHeader(status) - _, err := w.Write(data) - if err != nil { - logger.Errorln(err) - } - -} - -func securityHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - csp := "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; object-src 'none'" - w.Header().Add("content-security-policy", csp) - w.Header().Add("referrer-policy", "no-referrer") - w.Header().Add("x-content-type-options", "nosniff") - next.ServeHTTP(w, r) - }) -} - -func getTemplates(templates ...string) []string { - var pths []string - for _, t := range templates { - tmpl := path.Join("views", fmt.Sprintf("%s.tmpl", t)) - pths = append(pths, tmpl) - } - return pths -} - -func render(n string, w http.ResponseWriter, data interface{}) { - w.Header().Set("content-type", "text/html") - t := template.New(n + ".tmpl").Funcs(template.FuncMap{"extractURL": extractURL}) - t, err := t.ParseFiles(getTemplates(n, "navbar", "footer")...) - if err != nil { - logger.Errorln(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - - if err = t.Execute(w, data); err != nil { - logger.Errorln(err) - w.WriteHeader(http.StatusInternalServerError) - return - } -} - -const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36" - -var client = &http.Client{ - Timeout: 20 * time.Second, - Transport: &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 15 * time.Second, - KeepAlive: 15 * time.Second, - DualStack: true, - }).DialContext, - }, -} - -func sendRequest(u string) (*http.Response, error) { - url, err := url.Parse(u) - if err != nil { - return nil, err - } - - client.Transport, err = cfrt.New(client.Transport) - if err != nil { - return nil, err - } - - req := &http.Request{ - Method: http.MethodGet, - URL: url, - Header: map[string][]string{ - "Accept-Language": {"en-US"}, - "User-Agent": {UA}, - }, - } - - return client.Do(req) -} diff --git a/utils/clean_body.go b/utils/clean_body.go new file mode 100644 index 0000000..11150d3 --- /dev/null +++ b/utils/clean_body.go @@ -0,0 +1,37 @@ +package utils + +import ( + "fmt" + "regexp" + "strings" + + "github.com/PuerkitoBio/goquery" +) + +func CleanBody(body string) string { + if doc, err := goquery.NewDocumentFromReader(strings.NewReader(body)); err == nil { + doc.Find("iframe").Each(func(i int, s *goquery.Selection) { + src, exists := s.Attr("src") + if exists { + html := fmt.Sprintf(`Link`, src) + s.ReplaceWithHtml(html) + } + }) + + doc.Find("img").Each(func(i int, s *goquery.Selection) { + src, exists := s.Attr("src") + if exists { + re := regexp.MustCompile(`(?i)https:\/\/images\.(rapgenius|genius)\.com\/(images\/)?`) + pSrc := re.ReplaceAllString(src, "/images/") + s.SetAttr("src", pSrc) + } + }) + + if source, err := doc.Html(); err == nil { + body = source + } + } + + re := regexp.MustCompile(`https?:\/\/[a-z]*.?genius.com`) + return re.ReplaceAllString(body, "") +} diff --git a/utils/description.go b/utils/description.go new file mode 100644 index 0000000..4de94dd --- /dev/null +++ b/utils/description.go @@ -0,0 +1,9 @@ +package utils + +func TrimText(text string, keep int) string { + if len(text) > keep { + return text[0:keep] + "..." + } + + return text +} diff --git a/utils/logger.go b/utils/logger.go new file mode 100644 index 0000000..1d190f5 --- /dev/null +++ b/utils/logger.go @@ -0,0 +1,32 @@ +package utils + +import ( + "fmt" + "io" + "log/slog" + "os" +) + +type Logger struct { + slog *slog.Logger +} + +func NewLogger(w io.WriteCloser) *Logger { + handler := slog.NewTextHandler(w, &slog.HandlerOptions{}) + sl := slog.New(handler) + + return &Logger{slog: sl} +} + +func (l *Logger) Error(f string, args ...any) { + l.slog.Error(fmt.Sprintf(f, args...)) +} + +func (l *Logger) Info(f string, args ...any) { + l.slog.Info(fmt.Sprintf(f, args...)) +} + +func (l *Logger) Fatal(f string, args ...any) { + l.Error(f, args...) + os.Exit(1) +} diff --git a/utils/request.go b/utils/request.go new file mode 100644 index 0000000..6ef75d9 --- /dev/null +++ b/utils/request.go @@ -0,0 +1,58 @@ +package utils + +import ( + "fmt" + "net/http" + "net/url" + "os" + "time" +) + +func MustHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + csp := "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; object-src 'none'" + w.Header().Add("content-security-policy", csp) + w.Header().Add("referrer-policy", "no-referrer") + w.Header().Add("x-content-type-options", "nosniff") + w.Header().Add("content-type", "text/html") + next.ServeHTTP(w, r) + }) +} + +const UA = "Mozilla/5.0 (Windows NT 10.0; rv:123.0) Gecko/20100101 Firefox/123.0" + +func SendRequest(u string) (*http.Response, error) { + proxy := os.Getenv("PROXY") + url, err := url.Parse(u) + if err != nil { + return nil, err + } + + client := &http.Client{ + Timeout: 20 * time.Second, + } + + if proxy != "" { + proxyURL, err := url.Parse(proxy) + if err != nil { + return nil, fmt.Errorf("unable to parse proxy url: %s", err.Error()) + } + + client.Timeout = time.Minute * 1 + client.Transport = &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + } + + } + + headers := http.Header{} + headers.Set("user-agent", UA) + + req := &http.Request{ + Method: http.MethodGet, + URL: url, + Header: headers, + } + + return client.Do(req) +} diff --git a/utils/request_test.go b/utils/request_test.go new file mode 100644 index 0000000..e82fb37 --- /dev/null +++ b/utils/request_test.go @@ -0,0 +1,32 @@ +package utils + +/* +import ( + "encoding/json" + "testing" +) + +func TestSendRequest(t *testing.T) { + res, err := SendRequest("https://tls.peet.ws/api/clean") + if err != nil { + t.Fatal(err) + } + + defer res.Body.Close() + + type fingerprint struct { + JA3 string `json:"ja3"` + } + + decoder := json.NewDecoder(res.Body) + var fg fingerprint + + if err := decoder.Decode(&fg); err != nil { + t.Fatal(err) + } + + if fg.JA3 != JA3 { + t.Fatalf("expected %q, got %q\n", JA3, fg.JA3) + } +} +*/ diff --git a/utils/url.go b/utils/url.go new file mode 100644 index 0000000..c3454d4 --- /dev/null +++ b/utils/url.go @@ -0,0 +1,19 @@ +package utils + +import ( + "net/url" + "strings" +) + +func TrimURL(u string) string { + uu, err := url.Parse(u) + if err != nil { + return "" + } + + if strings.HasPrefix(uu.Path, "/") { + return uu.Path + } + + return "/" + uu.Path +} diff --git a/views/album.templ b/views/album.templ new file mode 100644 index 0000000..f655172 --- /dev/null +++ b/views/album.templ @@ -0,0 +1,40 @@ +package views + +import ( + "fmt" + "github.com/rramiachraf/dumb/data" + "github.com/rramiachraf/dumb/utils" +) + +templ AlbumPage(a data.Album) { + @layout(fmt.Sprintf("%s - %s", a.Artist.Name, a.Name)) { +
+
+ Album image +
+ +

{ a.Artist.Name }

+
+

{ a.Name }

+
+
+
+ for _, t := range a.Tracks { + + { fmt.Sprintf("%d", t.Number) } +

{ t.Title }

+
+ } +
+ if a.About != "" { +
+
+

About

+ +

{ utils.TrimText(a.About, 250) }

+
+
+ } +
+ } +} diff --git a/views/album.tmpl b/views/album.tmpl deleted file mode 100644 index db35d77..0000000 --- a/views/album.tmpl +++ /dev/null @@ -1,35 +0,0 @@ - - - - {{.Artist}} - {{.Name}} - - - - - - - {{template "navbar"}} -
-
- -

{{.Artist}}

-

{{.Name}}

-
-
- {{range .Tracks}} - -

{{.Title}}

-
- {{end}} -
-
-
-

About

- -

{{index .About 1}}

-
-
-
- {{template "footer"}} - - diff --git a/views/article.templ b/views/article.templ new file mode 100644 index 0000000..0b76597 --- /dev/null +++ b/views/article.templ @@ -0,0 +1,34 @@ +package views + +import ( + "time" + + "github.com/rramiachraf/dumb/data" +) + +templ ArticlePage(a data.Article) { + @layout(a.Title) { +
+
+

{ a.Title }

+ +
+ Article image +

{ a.Subtitle }

+
+ @templ.Raw(a.HTML) +
+
+

Authors

+ for _, author := range a.Authors { +
+ { author.Name } - { author.Role } + { author.About } +
+ } +
+
+ } +} diff --git a/views/artist.templ b/views/artist.templ new file mode 100644 index 0000000..732e6a1 --- /dev/null +++ b/views/artist.templ @@ -0,0 +1,49 @@ +package views + +import ( + "github.com/rramiachraf/dumb/data" + "github.com/rramiachraf/dumb/utils" +) + +templ ArtistPage(a data.Artist) { + @layout(a.Name) { +
+
+ Artist image +
+

{ a.Name }

+
+
+
+ if a.Description != "" { +
+

About { a.Name }

+
+

+ { utils.TrimText(a.Description, 500) } +

+ +
+
+ } + if len(a.Albums) > 0 { +
+

Albums

+
+ for _, album := range a.Albums { + + Album image +

{ album.Name }

+
+ } +
+
+ } +
+
+ } +} diff --git a/views/error.templ b/views/error.templ new file mode 100644 index 0000000..7bfe3ec --- /dev/null +++ b/views/error.templ @@ -0,0 +1,12 @@ +package views + +import "strconv" + +templ ErrorPage(code int, display string) { + @layout("Error - dumb") { +
+

{ strconv.Itoa(code) }

+

{ display }

+
+ } +} diff --git a/views/error.tmpl b/views/error.tmpl deleted file mode 100644 index 39e0729..0000000 --- a/views/error.tmpl +++ /dev/null @@ -1,20 +0,0 @@ - - - - dumb - - - - - - -
- {{template "navbar"}} -
-

{{.Status}}

-

{{.Error}}

-
- {{template "footer"}} - - - diff --git a/views/footer.templ b/views/footer.templ new file mode 100644 index 0000000..102451e --- /dev/null +++ b/views/footer.templ @@ -0,0 +1,15 @@ +package views + +import "github.com/rramiachraf/dumb/data" + +templ footer() { + +} diff --git a/views/footer.tmpl b/views/footer.tmpl deleted file mode 100644 index 394117c..0000000 --- a/views/footer.tmpl +++ /dev/null @@ -1,5 +0,0 @@ -{{define "footer"}} - -{{end}} diff --git a/views/head.templ b/views/head.templ new file mode 100644 index 0000000..b8a5d26 --- /dev/null +++ b/views/head.templ @@ -0,0 +1,13 @@ +package views + +templ head(title string) { + + { title } + + + + + + + +} diff --git a/views/home.templ b/views/home.templ new file mode 100644 index 0000000..3890c20 --- /dev/null +++ b/views/home.templ @@ -0,0 +1,15 @@ +package views + +templ HomePage() { + @layout("dumb") { +
+
+

Welcome to dumb

+

An alternative frontend for genius.com

+
+
+ +
+
+ } +} diff --git a/views/home.tmpl b/views/home.tmpl deleted file mode 100644 index 0b3397e..0000000 --- a/views/home.tmpl +++ /dev/null @@ -1,26 +0,0 @@ - - - - dumb - - - - - - -
- {{template "navbar"}} -
-
-

Welcome to dumb

-

An alternative frontend for genius.com

-
-
- -
- -
- {{template "footer"}} - - - diff --git a/views/layout.templ b/views/layout.templ new file mode 100644 index 0000000..d505ecf --- /dev/null +++ b/views/layout.templ @@ -0,0 +1,15 @@ +package views + +templ layout(title string) { + + + @head(title) + +
+ @navbar() + { children... } + @footer() +
+ + +} diff --git a/views/lyrics.templ b/views/lyrics.templ new file mode 100644 index 0000000..6bf58dc --- /dev/null +++ b/views/lyrics.templ @@ -0,0 +1,59 @@ +package views + +import ( + "fmt" + "github.com/rramiachraf/dumb/data" + "github.com/rramiachraf/dumb/utils" +) + +templ LyricsPage(s data.Song) { + @layout(fmt.Sprintf("%s - %s lyrics", s.Artist, s.Title)) { +
+
+ Song image +
+ +

{ s.Artist }

+
+

{ s.Title }

+
+
+
+ @templ.Raw(s.Lyrics) +
+
+ if s.About != "?" { +
+

About

+ +

{ utils.TrimText(s.About, 250) }

+
+ } + if len(s.Credits) > 0 { +
+

Credits

+ for key, val := range s.Credits { +
+ { key } +

{ val }

+
+ } +
+ } + if s.Album.Name != "" { +
+

{ s.Album.Name }

+ + Album image + +
+ } +
+
+ } +} diff --git a/views/lyrics.tmpl b/views/lyrics.tmpl deleted file mode 100644 index 5b9505d..0000000 --- a/views/lyrics.tmpl +++ /dev/null @@ -1,40 +0,0 @@ - - - - {{.Artist}} - {{.Title}} lyrics - - - - - - - - {{template "navbar"}} -
-
- -

{{.Artist}}

-

{{.Title}}

-

{{.Album}}

-
-
{{.Lyrics}}
-
-
-

About

- -

{{index .About 1}}

-
-
-

Credits

- {{range $key, $val := .Credits}} -
- {{$key}} -

{{$val}}

-
- {{end}} -
-
-
- {{template "footer"}} - - diff --git a/views/navbar.templ b/views/navbar.templ new file mode 100644 index 0000000..a8b6d4e --- /dev/null +++ b/views/navbar.templ @@ -0,0 +1,41 @@ +package views + +templ navbar() { + +} diff --git a/views/navbar.tmpl b/views/navbar.tmpl deleted file mode 100644 index bf117cd..0000000 --- a/views/navbar.tmpl +++ /dev/null @@ -1,5 +0,0 @@ -{{define "navbar"}} - -{{end}} diff --git a/views/search.templ b/views/search.templ new file mode 100644 index 0000000..908be95 --- /dev/null +++ b/views/search.templ @@ -0,0 +1,60 @@ +package views + +import ( + "github.com/rramiachraf/dumb/data" + "github.com/rramiachraf/dumb/utils" +) + +templ SearchPage(r data.SearchResults) { + @layout("Search - dumb") { +
+
+ +
+
+ for _, s := range r.Sections { + if s.Type == "song" { + + } + if s.Type == "album" { +
+

Albums

+ for _, a := range s.Hits { + + Album image +
+

{ utils.TrimText(a.Result.AlbumName, 70) }

+
+
+ } +
+ } + if s.Type == "artist" { +
+

Artists

+ for _, a := range s.Hits { + + Artist image +
+

{ utils.TrimText(a.Result.ArtistName, 70) }

+
+
+ } +
+ } + } +
+
+ } +} diff --git a/views/search.tmpl b/views/search.tmpl deleted file mode 100644 index eecea1b..0000000 --- a/views/search.tmpl +++ /dev/null @@ -1,37 +0,0 @@ - - - - Search - dumb - - - - - - -
- {{template "navbar"}} -
-
- -
-
- {{range .Sections}} - {{if eq .Type "song"}} -

Songs

- {{range .Hits}} - - -
- {{.Result.ArtistNames}} -

{{.Result.Title}}

-
-
- {{end}} - {{end}} - {{end}} -
-
- {{template "footer"}} - - -