mirror of
https://github.com/httpjamesm/AnonymousOverflow.git
synced 2024-12-06 19:16:43 +01:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81c4568564 | |||
| 9d77743865 | |||
| 5ec826a160 | |||
| 596ffaa02a | |||
| 42ad68fe34 | |||
| 634c7f1ad0 | |||
| 4db7b4795b | |||
| 13418054b4 | |||
| ea455f9317 | |||
| aa74bbc5fc | |||
| 56c9e4b196 | |||
| 16828585ac | |||
| cf89be2a48 | |||
| dcf0805334 | |||
| bb629da9a9 | |||
| d56a9309b9 | |||
| 10fc103fda | |||
| 991b5b46f4 | |||
| 7cf3839f41 | |||
| 3424b363d4 | |||
| 3d7129a01f |
@@ -23,7 +23,7 @@ tmp_dir = "tmp"
|
|||||||
rerun = false
|
rerun = false
|
||||||
rerun_delay = 500
|
rerun_delay = 500
|
||||||
send_interrupt = false
|
send_interrupt = false
|
||||||
stop_on_error = false
|
stop_on_error = true
|
||||||
|
|
||||||
[color]
|
[color]
|
||||||
app = ""
|
app = ""
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: Create and publish a Docker image
|
||||||
|
|
||||||
|
# Configures this workflow to run every time a change is pushed to the branch called `release`.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['release']
|
||||||
|
|
||||||
|
# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
#
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
|
||||||
|
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
|
||||||
|
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -14,26 +14,30 @@ This project is super lightweight by design. The UI is simple and the frontend i
|
|||||||
|
|
||||||
## Clearnet Instances
|
## Clearnet Instances
|
||||||
|
|
||||||
| Instance URL | Region | Notes |
|
| Instance URL | Region | Notes |
|
||||||
| ------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------ |
|
| ----------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
| [code.whatever.social](https://code.whatever.social) | United States & The Netherlands | Operated by [Whatever Social](https://whatever.social) and [http.james](https://httpjames.space) |
|
| [code.whatever.social](https://code.whatever.social) | United States & The Netherlands | Operated by [Whatever Social](https://whatever.social) and [http.james](https://httpjames.space) |
|
||||||
| [ao.vern.cc](https://ao.vern.cc) | United States | Operated by [vern.cc](https://vern.cc) |
|
| [ao.vern.cc](https://ao.vern.cc) | United States | Operated by [vern.cc](https://vern.cc) |
|
||||||
| [overflow.smnz.de](https://overflow.smnz.de) | Germany | Operated by [smnz.de](https://smnz.de) |
|
| [overflow.smnz.de](https://overflow.smnz.de) | Germany | Operated by [smnz.de](https://smnz.de) |
|
||||||
| [overflow.lunar.icu](https://overflow.lunar.icu) | Germany | Operated by [lunar.icu](https://lunar.icu/) |
|
| [overflow.lunar.icu](https://overflow.lunar.icu) | Germany | Operated by [lunar.icu](https://lunar.icu/) |
|
||||||
| [overflow.adminforge.de](https://overflow.adminforge.de/) | Germany | Operated by [adminForge](https://adminforge.de/) |
|
| [overflow.adminforge.de](https://overflow.adminforge.de/) | Germany | Operated by [adminForge](https://adminforge.de/) |
|
||||||
| [overflow.hostux.net](https://overflow.hostux.net/) | France | Operated by [Hostux](https://hostux.net/) |
|
| [overflow.hostux.net](https://overflow.hostux.net/) | France | Operated by [Hostux](https://hostux.net/) |
|
||||||
| [overflow.projectsegfau.lt](https://overflow.projectsegfau.lt/) | United States, France, India | Operated by [Project Segfault](https://projectsegfau.lt/) |
|
| [overflow.projectsegfau.lt](https://overflow.projectsegfau.lt/) | United States, France, India | Operated by [Project Segfault](https://projectsegfau.lt/) |
|
||||||
| [code.xbdm.fun](https://code.xbdm.fun) | Germany | Operated by [xbdm.fun](https://xbdm.fun) |
|
| [code.xbdm.fun](https://code.xbdm.fun) | Germany | Operated by [xbdm.fun](https://xbdm.fun) |
|
||||||
| [overflow.fascinated.cc](https://overflow.fascinated.cc/) | Germany | Operated by [fascinated.cc](https://fascinated.cc/) |
|
| [overflow.fascinated.cc](https://overflow.fascinated.cc/) | Germany | Operated by [fascinated.cc](https://fascinated.cc/) |
|
||||||
| [ao.bloatcat.tk](https://ao.bloatcat.tk) | Iceland | Operated by [bloatcat.tk](https://bloatcat.tk) |
|
| [ao.bloatcat.tk](https://ao.bloatcat.tk) | Iceland | Operated by [bloatcat.tk](https://bloatcat.tk) |
|
||||||
| [anonoverflow.frontendfriendly.xyz](https://anonoverflow.frontendfriendly.xyz/) | United States | Operated by [frontendfriendly.xyz](https://frontendfriendly.xyz/) |
|
| [anonoverflow.frontendfriendly.xyz](https://anonoverflow.frontendfriendly.xyz/) | United States | Operated by [frontendfriendly.xyz](https://frontendfriendly.xyz/) |
|
||||||
| [ao.owo.si](https://ao.owo.si/) | Germany | Operated by [owo.si](https://owo.si/) |
|
| [ao.owo.si](https://ao.owo.si/) | Germany | Operated by [owo.si](https://owo.si/) |
|
||||||
| [overflow.datura.network](https://overflow.datura.network/) | Germany | Operated by [datura.network](https://datura.network) |
|
| [overflow.datura.network](https://overflow.datura.network/) | Germany | Operated by [datura.network](https://datura.network) |
|
||||||
| [overflow.freedit.eu](overflow.freedit.eu) | United States | Operated by [freedit.eu](https://freedit.eu) |
|
| [overflow.freedit.eu](overflow.freedit.eu) | United States | Operated by [freedit.eu](https://freedit.eu) |
|
||||||
| [ao.ftw.lol](https://ao.ftw.lol) | Germany | Operated by [ftw.lol](https://ftw.lol) |
|
| [ao.ftw.lol](https://ao.ftw.lol) | Germany | Operated by [ftw.lol](https://ftw.lol) |
|
||||||
| [anonoverflow.hyperreal.coffee](https://anonoverflow.hyperreal.coffee) | United States | Operated by [hyperreal.coffee](https://hyperreal.coffee) |
|
| [anonoverflow.hyperreal.coffee](https://anonoverflow.hyperreal.coffee) | United States | Operated by [hyperreal.coffee](https://hyperreal.coffee) |
|
||||||
| [a.opnxng.com](a.opnxng.com) | Singapore | Operated by [opnxng.com](https://about.opnxng.com) |
|
| [a.opnxng.com](a.opnxng.com) | Singapore | Operated by [opnxng.com](https://about.opnxng.com) |
|
||||||
| [overflow.sudovanilla.com](https://overflow.sudovanilla.com) | United States | Operated by [SudoVanilla](https://sudovanilla.com) |
|
| [overflow.sudovanilla.com](https://overflow.sudovanilla.com) | United States | Operated by [SudoVanilla](https://sudovanilla.com) |
|
||||||
|
| [anonymousoverflow.privacyfucking.rocks](https://anonymousoverflow.privacyfucking.rocks/) | Germany | Operated by [privacyfucking.rocks](https://privacyfucking.rocks) |
|
||||||
|
| [exchange.seitan-ayoub.lol](https://exchange.seitan-ayoub.lol) | Germany | Operated by [Seitan Ayoub](https://seitan-ayoub.lol) |
|
||||||
|
| [overflow.r4fo.com](https://overflow.r4fo.com) | The Netherlands | Operated by [r4fo.com](https://r4fo.com) |
|
||||||
|
| [overflow.ducks.party](https://overflow.ducks.party) | The Netherlands | Operated by [ducks.party](https://ducks.party) |
|
||||||
|
|
||||||
## Other Instances
|
## Other Instances
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
var Version = "1.9.0"
|
var Version = "1.10.1"
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
anonymousoverflow:
|
anonymousoverflow:
|
||||||
container_name: 'app'
|
container_name: 'app'
|
||||||
build:
|
image: 'ghcr.io/httpjamesm/anonymousoverflow:release'
|
||||||
context: .
|
environment:
|
||||||
network: 'host'
|
- APP_URL=https://domain.com
|
||||||
environment:
|
- JWT_SIGNING_SECRET=secret
|
||||||
- APP_URL=https://domain.com
|
ports:
|
||||||
- JWT_SIGNING_SECRET=secret
|
- '80:8080'
|
||||||
ports:
|
restart: 'always'
|
||||||
- '80:8080'
|
|
||||||
restart: 'always'
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dlclark/regexp2 v1.7.0 // indirect
|
github.com/dlclark/regexp2 v1.7.0 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.0 // indirect
|
github.com/go-playground/locales v0.14.0 // indirect
|
||||||
@@ -25,6 +26,8 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
|
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/stretchr/testify v1.9.0 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.8 // indirect
|
github.com/ugorji/go/codec v1.2.8 // indirect
|
||||||
golang.org/x/crypto v0.4.0 // indirect
|
golang.org/x/crypto v0.4.0 // indirect
|
||||||
golang.org/x/net v0.7.0 // indirect
|
golang.org/x/net v0.7.0 // indirect
|
||||||
@@ -32,4 +35,5 @@ require (
|
|||||||
golang.org/x/text v0.7.0 // indirect
|
golang.org/x/text v0.7.0 // indirect
|
||||||
google.golang.org/protobuf v1.28.1 // indirect
|
google.golang.org/protobuf v1.28.1 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/ugorji/go/codec v1.2.8 h1:sgBJS6COt0b/P40VouWKdseidkDgHxYGm0SAglUHfP0=
|
github.com/ugorji/go/codec v1.2.8 h1:sgBJS6COt0b/P40VouWKdseidkDgHxYGm0SAglUHfP0=
|
||||||
github.com/ugorji/go/codec v1.2.8/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.8/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ func main() {
|
|||||||
r.POST("/", routes.PostHome)
|
r.POST("/", routes.PostHome)
|
||||||
|
|
||||||
r.GET("/a/:id", routes.RedirectShortenedOverflowURL)
|
r.GET("/a/:id", routes.RedirectShortenedOverflowURL)
|
||||||
|
r.GET("/q/:id", routes.RedirectShortenedOverflowURL)
|
||||||
|
|
||||||
r.GET("/questions/:id", func(c *gin.Context) {
|
r.GET("/questions/:id", func(c *gin.Context) {
|
||||||
// redirect user to the question with the title
|
// redirect user to the question with the title
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
+11
-15
@@ -1,6 +1,8 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
func OptionsMiddleware() gin.HandlerFunc {
|
func OptionsMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
@@ -8,23 +10,17 @@ func OptionsMiddleware() gin.HandlerFunc {
|
|||||||
c.Set("theme", "dark")
|
c.Set("theme", "dark")
|
||||||
|
|
||||||
imagesCookie, err := c.Cookie("disable_images")
|
imagesCookie, err := c.Cookie("disable_images")
|
||||||
if err != nil {
|
if err == nil {
|
||||||
c.Next()
|
if imagesCookie == "true" {
|
||||||
return
|
c.Set("disable_images", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if imagesCookie == "true" {
|
|
||||||
c.Set("disable_images", true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
themeCookie, err := c.Cookie("theme")
|
themeCookie, err := c.Cookie("theme")
|
||||||
if err != nil {
|
if err == nil {
|
||||||
c.Next()
|
if themeCookie == "light" {
|
||||||
return
|
c.Set("theme", "light")
|
||||||
}
|
}
|
||||||
|
|
||||||
if themeCookie == "light" {
|
|
||||||
c.Set("theme", "light")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ func PostHome(c *gin.Context) {
|
|||||||
|
|
||||||
// validate URL
|
// validate URL
|
||||||
isStackOverflow := strings.HasPrefix(soLink, "https://stackoverflow.com/questions/")
|
isStackOverflow := strings.HasPrefix(soLink, "https://stackoverflow.com/questions/")
|
||||||
isShortenedStackOverflow := strings.HasPrefix(soLink, "https://stackoverflow.com/a/")
|
isShortenedStackOverflow := strings.HasPrefix(soLink, "https://stackoverflow.com/a/") || strings.HasPrefix(soLink, "https://stackoverflow.com/q/")
|
||||||
isStackExchange := stackExchangeRegex.MatchString(soLink)
|
isStackExchange := stackExchangeRegex.MatchString(soLink)
|
||||||
if !isStackExchange && !isStackOverflow && !isShortenedStackOverflow {
|
if !isStackExchange && !isStackOverflow && !isShortenedStackOverflow {
|
||||||
c.HTML(400, "home.html", gin.H{
|
c.HTML(400, "home.html", gin.H{
|
||||||
|
|||||||
@@ -30,12 +30,10 @@ func ChangeOptions(c *gin.Context) {
|
|||||||
if c.MustGet("theme").(string) == "dark" {
|
if c.MustGet("theme").(string) == "dark" {
|
||||||
text = "light"
|
text = "light"
|
||||||
}
|
}
|
||||||
|
|
||||||
c.SetCookie("theme", text, 60*60*24*365*10, "/", "", false, true)
|
c.SetCookie("theme", text, 60*60*24*365*10, "/", "", false, true)
|
||||||
// get redirect url from query
|
// get redirect url from query
|
||||||
redirectUrl := c.Query("redirect_url")
|
redirectUrl := c.Query("redirect_url")
|
||||||
if redirectUrl == "" {
|
|
||||||
redirectUrl = os.Getenv("APP_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(redirectUrl, os.Getenv("APP_URL")) {
|
if !strings.HasPrefix(redirectUrl, os.Getenv("APP_URL")) {
|
||||||
redirectUrl = os.Getenv("APP_URL")
|
redirectUrl = os.Getenv("APP_URL")
|
||||||
|
|||||||
+175
-195
@@ -29,7 +29,6 @@ var soSortValues = map[string]string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ViewQuestion(c *gin.Context) {
|
func ViewQuestion(c *gin.Context) {
|
||||||
client := resty.New()
|
|
||||||
|
|
||||||
questionId := c.Param("id")
|
questionId := c.Param("id")
|
||||||
if _, err := strconv.Atoi(questionId); err != nil {
|
if _, err := strconv.Atoi(questionId); err != nil {
|
||||||
@@ -41,42 +40,24 @@ func ViewQuestion(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
questionTitle := c.Param("title")
|
params, err := parseAndValidateParameters(c)
|
||||||
|
if err != nil {
|
||||||
sortValue := c.Query("sort_by")
|
return
|
||||||
if sortValue == "" {
|
|
||||||
sortValue = "votes"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
soSortValue, ok := soSortValues[sortValue]
|
|
||||||
if !ok {
|
|
||||||
soSortValue = soSortValues["votes"]
|
|
||||||
}
|
|
||||||
|
|
||||||
sub := c.Param("sub")
|
|
||||||
|
|
||||||
domain := "stackoverflow.com"
|
domain := "stackoverflow.com"
|
||||||
|
|
||||||
if sub != "" {
|
if params.Sub != "" {
|
||||||
domain = fmt.Sprintf("%s.stackexchange.com", sub)
|
domain = fmt.Sprintf("%s.stackexchange.com", params.Sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
soLink := fmt.Sprintf("https://%s/questions/%s/%s?answertab=%s", domain, questionId, questionTitle, soSortValue)
|
soLink := fmt.Sprintf("https://%s/questions/%s/%s?answertab=%s", domain, questionId, params.QuestionTitle, params.SoSortValue)
|
||||||
|
|
||||||
resp, err := client.R().Get(soLink)
|
resp, err := fetchQuestionData(soLink)
|
||||||
if err != nil {
|
|
||||||
c.HTML(500, "home.html", gin.H{
|
|
||||||
"errorMessage": "Unable to fetch question data",
|
|
||||||
"theme": c.MustGet("theme").(string),
|
|
||||||
"version": config.Version,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.RawResponse.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode() != 200 {
|
if resp.StatusCode() != 200 {
|
||||||
c.HTML(500, "home.html", gin.H{
|
c.HTML(500, "home.html", gin.H{
|
||||||
"errorMessage": "Received a non-OK status code",
|
"errorMessage": fmt.Sprintf("Received a non-OK status code %d", resp.StatusCode()),
|
||||||
"theme": c.MustGet("theme").(string),
|
"theme": c.MustGet("theme").(string),
|
||||||
"version": config.Version,
|
"version": config.Version,
|
||||||
})
|
})
|
||||||
@@ -97,181 +78,25 @@ func ViewQuestion(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newFilteredQuestion := types.FilteredQuestion{}
|
newFilteredQuestion, err := extractQuestionData(doc, domain)
|
||||||
|
|
||||||
questionTextParent := doc.Find("h1.fs-headline1")
|
|
||||||
|
|
||||||
questionText := questionTextParent.Children().First().Text()
|
|
||||||
|
|
||||||
newFilteredQuestion.Title = questionText
|
|
||||||
|
|
||||||
questionPostLayout := doc.Find("div.post-layout").First()
|
|
||||||
|
|
||||||
questionTags := utils.GetPostTags(questionPostLayout)
|
|
||||||
newFilteredQuestion.Tags = questionTags
|
|
||||||
|
|
||||||
questionBodyParent := doc.Find("div.s-prose")
|
|
||||||
|
|
||||||
questionBodyParentHTML, err := questionBodyParent.Html()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.HTML(500, "home.html", gin.H{
|
c.HTML(500, "home.html", gin.H{
|
||||||
"errorMessage": "Unable to parse question body",
|
"errorMessage": "Failed to extract question data",
|
||||||
"theme": c.MustGet("theme").(string),
|
"theme": c.MustGet("theme").(string),
|
||||||
"version": config.Version,
|
"version": config.Version,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newFilteredQuestion.Body = template.HTML(utils.ReplaceImgTags(questionBodyParentHTML))
|
answers, err := extractAnswersData(doc, domain)
|
||||||
|
if err != nil {
|
||||||
questionBodyText := questionBodyParent.Text()
|
c.HTML(500, "home.html", gin.H{
|
||||||
|
"errorMessage": "Failed to extract answer data",
|
||||||
// remove all whitespace to create the shortened body desc
|
"theme": c.MustGet("theme").(string),
|
||||||
shortenedBody := strings.TrimSpace(questionBodyText)
|
"version": config.Version,
|
||||||
|
|
||||||
// remove all newlines
|
|
||||||
shortenedBody = strings.ReplaceAll(shortenedBody, "\n", " ")
|
|
||||||
|
|
||||||
// get the first 50 chars
|
|
||||||
shortenedBody = shortenedBody[:50]
|
|
||||||
|
|
||||||
newFilteredQuestion.ShortenedBody = shortenedBody
|
|
||||||
|
|
||||||
comments := utils.FindAndReturnComments(questionBodyParentHTML, domain, questionPostLayout)
|
|
||||||
newFilteredQuestion.Comments = comments
|
|
||||||
|
|
||||||
// parse any code blocks and highlight them
|
|
||||||
answerCodeBlocks := questionCodeBlockRegex.FindAllString(questionBodyParentHTML, -1)
|
|
||||||
for _, codeBlock := range answerCodeBlocks {
|
|
||||||
codeBlock = utils.StripBlockTags(codeBlock)
|
|
||||||
|
|
||||||
// syntax highlight
|
|
||||||
highlightedCodeBlock := utils.HighlightSyntaxViaContent(codeBlock)
|
|
||||||
|
|
||||||
// replace the code block with the highlighted code block
|
|
||||||
questionBodyParentHTML = strings.Replace(questionBodyParentHTML, codeBlock, highlightedCodeBlock, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
questionCard := doc.Find("div.postcell")
|
|
||||||
|
|
||||||
questionMetadata := questionCard.Find("div.user-info")
|
|
||||||
questionTimestamp := ""
|
|
||||||
questionMetadata.Find("span.relativetime").Each(func(i int, s *goquery.Selection) {
|
|
||||||
// get the second
|
|
||||||
if i == 0 {
|
|
||||||
if s.Text() != "" {
|
|
||||||
// if it's not been edited, it means it's the first
|
|
||||||
questionTimestamp = s.Text()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise it's the second element
|
|
||||||
if i == 1 {
|
|
||||||
questionTimestamp = s.Text()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
newFilteredQuestion.Timestamp = questionTimestamp
|
|
||||||
|
|
||||||
userDetails := questionMetadata.Find("div.user-details")
|
|
||||||
|
|
||||||
questionAuthor := ""
|
|
||||||
questionAuthorURL := fmt.Sprintf("https://%s", domain)
|
|
||||||
|
|
||||||
// check if the question has been edited
|
|
||||||
isQuestionEdited := false
|
|
||||||
questionMetadata.Find("a.js-gps-track").Each(func(i int, s *goquery.Selection) {
|
|
||||||
if strings.Contains(s.Text(), "edited") {
|
|
||||||
isQuestionEdited = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
userDetails.Find("a").Each(func(i int, s *goquery.Selection) {
|
|
||||||
// if question has been edited, the author is the second element.
|
|
||||||
|
|
||||||
if isQuestionEdited {
|
|
||||||
if i == 1 {
|
|
||||||
questionAuthor = s.Text()
|
|
||||||
questionAuthorURL += s.AttrOr("href", "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// otherwise it's the first element
|
|
||||||
if i == 0 {
|
|
||||||
questionAuthor = s.Text()
|
|
||||||
questionAuthorURL += s.AttrOr("href", "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
newFilteredQuestion.AuthorName = questionAuthor
|
|
||||||
newFilteredQuestion.AuthorURL = questionAuthorURL
|
|
||||||
|
|
||||||
answers := []types.FilteredAnswer{}
|
|
||||||
|
|
||||||
doc.Find("div.answer").Each(func(i int, s *goquery.Selection) {
|
|
||||||
|
|
||||||
newFilteredAnswer := types.FilteredAnswer{}
|
|
||||||
|
|
||||||
postLayout := s.Find("div.post-layout")
|
|
||||||
voteCell := postLayout.Find("div.votecell")
|
|
||||||
answerCell := postLayout.Find("div.answercell")
|
|
||||||
answerBody := answerCell.Find("div.s-prose")
|
|
||||||
answerBodyHTML, _ := answerBody.Html()
|
|
||||||
|
|
||||||
voteCount := html.EscapeString(voteCell.Find("div.js-vote-count").Text())
|
|
||||||
|
|
||||||
newFilteredAnswer.Upvotes = voteCount
|
|
||||||
newFilteredAnswer.IsAccepted = s.HasClass("accepted-answer")
|
|
||||||
|
|
||||||
answerFooter := s.Find("div.mt24")
|
|
||||||
|
|
||||||
answerAuthorURL := fmt.Sprintf("https://%s", domain)
|
|
||||||
answerAuthorName := ""
|
|
||||||
answerTimestamp := ""
|
|
||||||
|
|
||||||
answerFooter.Find("div.post-signature").Each(func(i int, s *goquery.Selection) {
|
|
||||||
answerAuthorDetails := s.Find("div.user-details")
|
|
||||||
|
|
||||||
if answerAuthorDetails.Length() > 0 {
|
|
||||||
questionAuthor := answerAuthorDetails.Find("a").First()
|
|
||||||
answerAuthorName = html.EscapeString(questionAuthor.Text())
|
|
||||||
answerAuthorURL += html.EscapeString(questionAuthor.AttrOr("href", ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
answerTimestamp = html.EscapeString(s.Find("span.relativetime").Text())
|
|
||||||
})
|
})
|
||||||
|
return
|
||||||
answerId, _ := s.Attr("data-answerid")
|
}
|
||||||
newFilteredAnswer.ID = answerId
|
|
||||||
|
|
||||||
newFilteredAnswer.AuthorName = answerAuthorName
|
|
||||||
newFilteredAnswer.AuthorURL = answerAuthorURL
|
|
||||||
newFilteredAnswer.Timestamp = answerTimestamp
|
|
||||||
|
|
||||||
// parse any code blocks and highlight them
|
|
||||||
answerCodeBlocks := codeBlockRegex.FindAllString(answerBodyHTML, -1)
|
|
||||||
for _, codeBlock := range answerCodeBlocks {
|
|
||||||
codeBlock = utils.StripBlockTags(codeBlock)
|
|
||||||
|
|
||||||
// syntax highlight
|
|
||||||
highlightedCodeBlock := utils.HighlightSyntaxViaContent(codeBlock)
|
|
||||||
|
|
||||||
// replace the code block with the highlighted code block
|
|
||||||
answerBodyHTML = strings.Replace(answerBodyHTML, codeBlock, highlightedCodeBlock, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
comments = utils.FindAndReturnComments(answerBodyHTML, domain, postLayout)
|
|
||||||
|
|
||||||
newFilteredAnswer.Comments = comments
|
|
||||||
newFilteredAnswer.Body = template.HTML(utils.ReplaceImgTags(answerBodyHTML))
|
|
||||||
|
|
||||||
answers = append(answers, newFilteredAnswer)
|
|
||||||
})
|
|
||||||
|
|
||||||
imagePolicy := "'self' https:"
|
imagePolicy := "'self' https:"
|
||||||
|
|
||||||
@@ -284,9 +109,164 @@ func ViewQuestion(c *gin.Context) {
|
|||||||
"answers": answers,
|
"answers": answers,
|
||||||
"imagePolicy": imagePolicy,
|
"imagePolicy": imagePolicy,
|
||||||
"theme": c.MustGet("theme").(string),
|
"theme": c.MustGet("theme").(string),
|
||||||
"currentUrl": fmt.Sprintf("%s/questions/%s/%s", os.Getenv("APP_URL"), questionId, questionTitle),
|
"currentUrl": fmt.Sprintf("%s/questions/%s/%s", os.Getenv("APP_URL"), questionId, params.QuestionTitle),
|
||||||
"sortValue": sortValue,
|
"sortValue": params.SoSortValue,
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type viewQuestionInputs struct {
|
||||||
|
QuestionID string
|
||||||
|
QuestionTitle string
|
||||||
|
SoSortValue string
|
||||||
|
Sub string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAndValidateParameters consolidates the URL and query parameters into an easily-accessible struct.
|
||||||
|
func parseAndValidateParameters(c *gin.Context) (inputs viewQuestionInputs, err error) {
|
||||||
|
|
||||||
|
questionId := c.Param("id")
|
||||||
|
if _, err = strconv.Atoi(questionId); err != nil {
|
||||||
|
c.HTML(400, "home.html", gin.H{
|
||||||
|
"errorMessage": "Invalid question ID",
|
||||||
|
"theme": c.MustGet("theme").(string),
|
||||||
|
"version": config.Version,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs.QuestionID = questionId
|
||||||
|
|
||||||
|
sortValue := c.Query("sort_by")
|
||||||
|
if sortValue == "" {
|
||||||
|
sortValue = "votes"
|
||||||
|
}
|
||||||
|
|
||||||
|
soSortValue, ok := soSortValues[sortValue]
|
||||||
|
if !ok {
|
||||||
|
soSortValue = soSortValues["votes"]
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs.SoSortValue = soSortValue
|
||||||
|
|
||||||
|
sub := c.Param("sub")
|
||||||
|
|
||||||
|
inputs.Sub = sub
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchQuestionData sends the request to StackOverflow.
|
||||||
|
func fetchQuestionData(soLink string) (resp *resty.Response, err error) {
|
||||||
|
client := resty.New()
|
||||||
|
resp, err = client.R().Get(soLink)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractQuestionData parses the HTML document and extracts question data.
|
||||||
|
func extractQuestionData(doc *goquery.Document, domain string) (question types.FilteredQuestion, err error) {
|
||||||
|
// Extract the question title.
|
||||||
|
questionTextParent := doc.Find("h1.fs-headline1").First()
|
||||||
|
question.Title = strings.TrimSpace(questionTextParent.Children().First().Text())
|
||||||
|
|
||||||
|
// Extract question tags.
|
||||||
|
questionTags := utils.GetPostTags(doc.Find("div.post-layout").First())
|
||||||
|
question.Tags = questionTags
|
||||||
|
|
||||||
|
// Extract and process the question body.
|
||||||
|
questionBodyParent := doc.Find("div.s-prose").First()
|
||||||
|
questionBodyParentHTML, err := questionBodyParent.Html()
|
||||||
|
if err != nil {
|
||||||
|
return question, err
|
||||||
|
}
|
||||||
|
question.Body = template.HTML(utils.ProcessHTMLBody(questionBodyParentHTML))
|
||||||
|
|
||||||
|
// Extract the shortened body description.
|
||||||
|
shortenedBody := strings.TrimSpace(questionBodyParent.Text())
|
||||||
|
shortenedBody = strings.ReplaceAll(shortenedBody, "\n", " ")
|
||||||
|
if len(shortenedBody) > 50 {
|
||||||
|
shortenedBody = shortenedBody[:50]
|
||||||
|
}
|
||||||
|
question.ShortenedBody = shortenedBody
|
||||||
|
|
||||||
|
// Extract question comments.
|
||||||
|
comments := utils.FindAndReturnComments(questionBodyParentHTML, domain, doc.Find("div.post-layout").First())
|
||||||
|
question.Comments = comments
|
||||||
|
|
||||||
|
// Extract question timestamp and author information.
|
||||||
|
questionCard := doc.Find("div.postcell").First()
|
||||||
|
extractMetadata(questionCard, &question, domain)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractMetadata extracts author and timestamp information from a given selection.
|
||||||
|
func extractMetadata(selection *goquery.Selection, question *types.FilteredQuestion, domain string) {
|
||||||
|
questionMetadata := selection.Find("div.user-info").First()
|
||||||
|
question.Timestamp = questionMetadata.Find("span.relativetime").First().Text()
|
||||||
|
|
||||||
|
questionAuthorURL := "https://" + domain
|
||||||
|
questionAuthor := selection.Find("div.post-signature.owner div.user-info div.user-details a").First()
|
||||||
|
question.AuthorName = questionAuthor.Text()
|
||||||
|
questionAuthorURL += questionAuthor.AttrOr("href", "")
|
||||||
|
question.AuthorURL = questionAuthorURL
|
||||||
|
|
||||||
|
// Determine if the question has been edited and update author details accordingly.
|
||||||
|
isQuestionEdited := selection.Find("a.js-gps-track").Text() == "edited"
|
||||||
|
if isQuestionEdited {
|
||||||
|
editedAuthor := questionMetadata.Find("a").Last()
|
||||||
|
question.AuthorName = editedAuthor.Text()
|
||||||
|
question.AuthorURL = "https://" + domain + editedAuthor.AttrOr("href", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAnswersData parses the HTML document and extracts answers data.
|
||||||
|
func extractAnswersData(doc *goquery.Document, domain string) ([]types.FilteredAnswer, error) {
|
||||||
|
var answers []types.FilteredAnswer
|
||||||
|
|
||||||
|
// Iterate over each answer block.
|
||||||
|
doc.Find("div.answer").Each(func(i int, s *goquery.Selection) {
|
||||||
|
var answer types.FilteredAnswer
|
||||||
|
|
||||||
|
postLayout := s.Find("div.post-layout").First()
|
||||||
|
|
||||||
|
// Extract upvotes.
|
||||||
|
voteCell := postLayout.Find("div.votecell").First()
|
||||||
|
voteCount := html.EscapeString(voteCell.Find("div.js-vote-count").Text())
|
||||||
|
answer.Upvotes = voteCount
|
||||||
|
|
||||||
|
// Check if the answer is accepted.
|
||||||
|
answer.IsAccepted = s.HasClass("accepted-answer")
|
||||||
|
|
||||||
|
// Extract answer body and process it.
|
||||||
|
answerCell := postLayout.Find("div.answercell").First()
|
||||||
|
answerBody := answerCell.Find("div.s-prose").First()
|
||||||
|
answerBodyHTML, _ := answerBody.Html()
|
||||||
|
|
||||||
|
// Process code blocks within the answer.
|
||||||
|
processedAnswerBody := utils.ProcessHTMLBody(answerBodyHTML)
|
||||||
|
answer.Body = template.HTML(html.UnescapeString(processedAnswerBody))
|
||||||
|
|
||||||
|
// Extract author information and timestamp.
|
||||||
|
extractAnswerAuthorInfo(s, &answer, domain)
|
||||||
|
|
||||||
|
answers = append(answers, answer)
|
||||||
|
})
|
||||||
|
|
||||||
|
return answers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAnswerAuthorInfo extracts the author name, URL, and timestamp from an answer block.
|
||||||
|
// It directly mutates the answer.
|
||||||
|
func extractAnswerAuthorInfo(selection *goquery.Selection, answer *types.FilteredAnswer, domain string) {
|
||||||
|
authorDetails := selection.Find("div.post-signature").Last()
|
||||||
|
|
||||||
|
authorName := html.EscapeString(authorDetails.Find("div.user-details a").First().Text())
|
||||||
|
authorURL := "https://" + domain + authorDetails.Find("div.user-details a").AttrOr("href", "")
|
||||||
|
timestamp := html.EscapeString(authorDetails.Find("span.relativetime").Text())
|
||||||
|
|
||||||
|
answer.AuthorName = authorName
|
||||||
|
answer.AuthorURL = authorURL
|
||||||
|
answer.Timestamp = timestamp
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func FindAndReturnComments(inHtml, domain string, postLayout *goquery.Selection)
|
|||||||
commentTimestamp := commentBody.Find("span.relativetime-clean").Text()
|
commentTimestamp := commentBody.Find("span.relativetime-clean").Text()
|
||||||
|
|
||||||
newFilteredComment := types.FilteredComment{
|
newFilteredComment := types.FilteredComment{
|
||||||
Text: template.HTML(commentCopy),
|
Text: template.HTML(ProcessHTMLBody(commentCopy)),
|
||||||
Timestamp: commentTimestamp,
|
Timestamp: commentTimestamp,
|
||||||
AuthorName: commentAuthor.Text(),
|
AuthorName: commentAuthor.Text(),
|
||||||
AuthorURL: commentAuthorURL,
|
AuthorURL: commentAuthorURL,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stackOverflowLinkQualifierRegex matches all anchor elements that meet the following conditions:
|
||||||
|
// * must be an anchor element
|
||||||
|
// * the anchor element must have a pathname beginning with /q or /questions
|
||||||
|
// * if there is a host, it must be stackoverflow.com or a subdomain
|
||||||
|
var stackOverflowLinkQualifierRegex = regexp.MustCompile(`<a\s[^>]*href="(?:https?://(?:www\.)?(?:\w+\.)*(?:stackoverflow|stackexchange)\.com)?/(?:q|questions)/[^"]*"[^>]*>.*?</a>`)
|
||||||
|
|
||||||
|
func ReplaceStackOverflowLinks(html string) string {
|
||||||
|
return stackOverflowLinkQualifierRegex.ReplaceAllStringFunc(html, func(match string) string {
|
||||||
|
// Extract the href attribute value from the anchor tag
|
||||||
|
hrefRegex := regexp.MustCompile(`href="([^"]*)"`)
|
||||||
|
hrefMatch := hrefRegex.FindStringSubmatch(match)
|
||||||
|
if len(hrefMatch) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
href := hrefMatch[1]
|
||||||
|
|
||||||
|
// Parse the URL
|
||||||
|
url, err := url.Parse(href)
|
||||||
|
if err != nil {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
newUrl := url.String()
|
||||||
|
|
||||||
|
// Check if the host is a subdomain
|
||||||
|
parts := strings.Split(url.Host, ".")
|
||||||
|
if len(parts) > 2 {
|
||||||
|
// Prepend the subdomain to the path
|
||||||
|
url.Path = "/exchange/" + parts[0] + url.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
newUrl = url.Path + url.RawQuery + url.Fragment
|
||||||
|
|
||||||
|
// Replace the href attribute value in the anchor tag
|
||||||
|
return strings.Replace(match, hrefMatch[1], newUrl, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var sampleInput = `<div class="d-flex fd-column fw-nowrap">
|
||||||
|
<div class="d-flex fw-nowrap">
|
||||||
|
<div class="flex--item wmn0 fl1 lh-lg">
|
||||||
|
<div class="flex--item fl1 lh-lg">
|
||||||
|
<div>
|
||||||
|
<b>This question already has answers here</b>:
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex--item mb0 mt4">
|
||||||
|
<a href="/questions/55083952/is-it-possible-to-populate-a-large-set-at-compile-time" dir="ltr">Is it possible to populate a large set at compile time?</a>
|
||||||
|
<span class="question-originals-answer-count">
|
||||||
|
(3 answers)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex--item mb0 mt4">
|
||||||
|
<a href="https://stackoverflow.com/questions/27221504/how-can-you-make-a-safe-static-singleton-in-rust" dir="ltr">How can you make a safe static singleton in Rust?</a>
|
||||||
|
<span class="question-originals-answer-count">
|
||||||
|
(5 answers)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex--item mb0 mt4">
|
||||||
|
<a href="https://security.stackexchange.com/questions/25371/brute-force-an-ssh-login-that-has-only-a-4-letter-password" dir="ltr">Brute-force an SSH-login that has only a 4-letter password</a>
|
||||||
|
<span class="question-originals-answer-count">
|
||||||
|
(9 answers)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex--item mb0 mt8">Closed <span title="2020-01-29 14:28:42Z" class="relativetime">4 years ago</span>.</div>
|
||||||
|
</div>`
|
||||||
|
|
||||||
|
func TestReplaceStackOverflowLinks(t *testing.T) {
|
||||||
|
replacedLinks := ReplaceStackOverflowLinks(sampleInput)
|
||||||
|
|
||||||
|
fmt.Println(replacedLinks)
|
||||||
|
|
||||||
|
assert.False(t, strings.Contains(replacedLinks, "stackoverflow.com"))
|
||||||
|
assert.False(t, strings.Contains(replacedLinks, "stackexchange.com"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
// ProcessHTMLBody runs HTML through the various preparation functions.
|
||||||
|
func ProcessHTMLBody(bodyHTML string) string {
|
||||||
|
highlightedBody := HighlightCodeBlocks(bodyHTML)
|
||||||
|
imageProxiedBody := ReplaceImgTags(highlightedBody)
|
||||||
|
stackOverflowLinksReplacedBody := ReplaceStackOverflowLinks(imageProxiedBody)
|
||||||
|
return stackOverflowLinksReplacedBody
|
||||||
|
}
|
||||||
+30
-5
@@ -12,7 +12,9 @@ import (
|
|||||||
"github.com/alecthomas/chroma/styles"
|
"github.com/alecthomas/chroma/styles"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HighlightSyntaxViaContent(content string) (htmlOut string) {
|
// highlightSyntaxViaContent uses Chroma to lex code content and apply the appropriate tokenizer engine.
|
||||||
|
// If it can't find one, it defaults to JavaScript syntax highlighting.
|
||||||
|
func highlightSyntaxViaContent(content string) (htmlOut string) {
|
||||||
content = html.UnescapeString(content)
|
content = html.UnescapeString(content)
|
||||||
|
|
||||||
fallbackOut := html.EscapeString(content)
|
fallbackOut := html.EscapeString(content)
|
||||||
@@ -20,9 +22,7 @@ func HighlightSyntaxViaContent(content string) (htmlOut string) {
|
|||||||
// identify the language
|
// identify the language
|
||||||
lexer := lexers.Analyse(content)
|
lexer := lexers.Analyse(content)
|
||||||
if lexer == nil {
|
if lexer == nil {
|
||||||
// unable to identify, so just return the wrapped content
|
lexer = lexers.Get(".js")
|
||||||
htmlOut = fallbackOut
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
style := styles.Get("xcode")
|
style := styles.Get("xcode")
|
||||||
@@ -54,7 +54,9 @@ func HighlightSyntaxViaContent(content string) (htmlOut string) {
|
|||||||
|
|
||||||
var preClassRegex = regexp.MustCompile(`(?s)<pre class=".+">`)
|
var preClassRegex = regexp.MustCompile(`(?s)<pre class=".+">`)
|
||||||
|
|
||||||
func StripBlockTags(content string) (result string) {
|
// stripBlockTags takes an extracted code block from HTML and strips it of its pre and code tags.
|
||||||
|
// What's returned is just the code.
|
||||||
|
func stripBlockTags(content string) (result string) {
|
||||||
// strip all "<code>" tags
|
// strip all "<code>" tags
|
||||||
content = strings.Replace(content, "<code>", "", -1)
|
content = strings.Replace(content, "<code>", "", -1)
|
||||||
content = strings.Replace(content, "</code>", "", -1)
|
content = strings.Replace(content, "</code>", "", -1)
|
||||||
@@ -68,3 +70,26 @@ func StripBlockTags(content string) (result string) {
|
|||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var codeBlockRegex = regexp.MustCompile(`(?s)<pre><code>(.*?)<\/code><\/pre>`)
|
||||||
|
|
||||||
|
// HighlightCodeBlocks uses both highlightSyntaxViaContent stripCodeBlocks and returns the newly highlighted code HTML.
|
||||||
|
func HighlightCodeBlocks(html string) string {
|
||||||
|
// Replace each code block with the highlighted version
|
||||||
|
highlightedHTML := codeBlockRegex.ReplaceAllStringFunc(html, func(codeBlock string) string {
|
||||||
|
// Extract the code content from the code block
|
||||||
|
codeContent := codeBlockRegex.FindStringSubmatch(codeBlock)[1]
|
||||||
|
|
||||||
|
codeContent = stripBlockTags(codeContent)
|
||||||
|
|
||||||
|
// Highlight the code content
|
||||||
|
highlightedCode := highlightSyntaxViaContent(codeContent)
|
||||||
|
|
||||||
|
// Replace the original code block with the highlighted version
|
||||||
|
highlightedCodeBlock := "<pre>" + highlightedCode + "</pre>"
|
||||||
|
|
||||||
|
return highlightedCodeBlock
|
||||||
|
})
|
||||||
|
|
||||||
|
return highlightedHTML
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@
|
|||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'none'; style-src 'self'; script-src 'none'; img-src 'self';"
|
content="default-src 'none'; style-src 'self'; script-src 'none'; img-src 'self';"
|
||||||
/>
|
/>
|
||||||
<link rel="icon" href="/static/codecircles.png" />
|
<link rel="icon" href="/static/codecircles.webp" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="View StackOverflow threads in privacy and without the clutter."
|
content="View StackOverflow threads in privacy and without the clutter."
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="title">
|
<div class="title">
|
||||||
<img
|
<img
|
||||||
class="logo"
|
class="logo"
|
||||||
src="/static/codecircles.png"
|
src="/static/codecircles.webp"
|
||||||
alt="4 circles with alternating colors between green and white"
|
alt="4 circles with alternating colors between green and white"
|
||||||
/>
|
/>
|
||||||
<h1>AnonymousOverflow</h1>
|
<h1>AnonymousOverflow</h1>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<a href="/" class="logo-link">
|
<a href="/" class="logo-link">
|
||||||
<img
|
<img
|
||||||
class="logo"
|
class="logo"
|
||||||
src="/static/codecircles.png"
|
src="/static/codecircles.webp"
|
||||||
alt="4 circles with alternating colors between green and white"
|
alt="4 circles with alternating colors between green and white"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<meta name="theme-color" content="#8CFFC1" />
|
<meta name="theme-color" content="#8CFFC1" />
|
||||||
<meta name="og:image" content="/static/codecircles.png" />
|
<meta name="og:image" content="/static/codecircles.webp" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="stylesheet" href="/static/globals.css" />
|
<link rel="stylesheet" href="/static/globals.css" />
|
||||||
|
|||||||
Reference in New Issue
Block a user