Compare commits

..

24 Commits

Author SHA1 Message Date
httpjamesm 17a8c6b1e1 feat: version 1.3.3 2022-12-29 14:47:16 -05:00
httpjamesm be9380ba20 fix: allow backwards compatibility with older threads 2022-12-29 14:47:07 -05:00
httpjamesm e4d429b642 fix: set action to / for conversion 2022-12-29 14:46:47 -05:00
httpjamesm b35c75cdf1 docs: "why use anonymousoverflow" 2022-12-29 14:09:37 -05:00
httpjamesm c46820b6d4 fix: add version, rolling ratelimit 2022-12-29 13:54:37 -05:00
httpjamesm 0e9677ec61 feat: success message indicator, better UX for image toggling 2022-12-29 13:52:17 -05:00
httpjamesm 4ddc1a2af7 feat: send human-readable error for 429 2022-12-29 13:49:19 -05:00
httpjamesm e476087a3a feat: validate question id and send error messages 2022-12-29 13:45:04 -05:00
httpjamesm 3ff8e8cb96 refactor: move theme switcher to a partial 2022-12-29 13:19:08 -05:00
httpjamesm 1d12372ea5 refactor: move shared head components to a template 2022-12-29 13:17:50 -05:00
httpjamesm b038d2c502 refactor: convert comments to a reusable template 2022-12-29 13:16:16 -05:00
httpjamesm e6915ae849 fix: re-add question comments 2022-12-29 13:11:33 -05:00
httpjamesm 572fac9ec3 feat: version 1.3.2 2022-12-29 13:09:35 -05:00
httpjamesm 11efaf854d feat: comment text to HTML with sanitization 2022-12-29 13:09:25 -05:00
httpjamesm 3b79efc83c refactor: rely heavier on the templating engine
style: fix comments spacing on mobile
2022-12-29 13:06:14 -05:00
httpjamesm c0df07abad refactor: use existing PreventSurroundingPre 2022-12-29 12:22:59 -05:00
httpjamesm 10e7c89e96 feat: more escaping, tighter security 2022-12-28 23:56:14 -05:00
httpjamesm 6f86cbce6e feat: update version num to 1.3 2022-12-28 22:55:42 -05:00
httpjamesm 1d2e98e4c1 style: add margin between comments and divider 2022-12-28 22:55:30 -05:00
httpjamesm 599d23544b feat: unify comments finder, show post comments
style: show post notices in a clean layout
2022-12-28 22:54:13 -05:00
httpjamesm 84ba0886cd feat: show answer comments under collapsible 2022-12-28 22:48:02 -05:00
httpjamesm 5bd9ce484f fix: escape code blocks to prevent HTML injection 2022-12-28 22:18:18 -05:00
httpjamesm 8f9091fb2a docs: add chroma to attribution 2022-12-28 19:20:58 -05:00
httpjamesm 1b1120d924 docs: attribution with licenses 2022-12-28 19:19:53 -05:00
25 changed files with 441 additions and 103 deletions
+3 -1
View File
@@ -1,2 +1,4 @@
.env
docker-compose.yml
docker-compose.yml
.DS_Store
*bin
+15
View File
@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "."
}
]
}
+32 -2
View File
@@ -27,6 +27,29 @@ This project is super lightweight by design. The UI is simple and the frontend i
| [ao.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion](http://ao.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion) | United States | Operated by [vern.cc](https://vern.cc) |
| [vernmzgraj6aaoafmehupvtkkynpaa67rxcdj2kinwiy6konn6rq.b32.i2p](http://vernmzgraj6aaoafmehupvtkkynpaa67rxcdj2kinwiy6konn6rq.b32.i2p) | United States | Operated by [vern.cc](https://vern.cc) |
## Why use AnonymousOverflow over StackOverflow?
- StackOverflow collects a lot of information
While it's understandable that StackOverflow collects a lot of technical data for product development, debugging and to serve the best experience to its users, not everyone wants their
> internet protocol (IP) address, [...] browser type and version, time zone setting and location, browser plug-in types and versions, operating system, and platform [...] data
to be collected and stored.
- StackOverflow shares your information with third-parties
StackOverflow does not sell your information, but it does share it with third-parties, including conglomerates.
> We also partner with other third parties, such as Google Ads and Microsoft Bing, to serve advertising content and manage advertising campaigns. When we use Google Ads or Microsoft Bing Customer Match for advertising campaigns, your personal data will be protected using hashed codes.
Google users can control the ads that they see on Google services, including Customer Match ads, in their Google Ads Settings.
Their main website also [contains trackers from Alphabet](https://themarkup.org/blacklight?url=stackoverflow.com).
- Reduced clutter
StackOverflow has a cluttered UI that might distract you from the content you're trying to find. AnonymousOverflow simplifies the interface to make it easier to read and navigate.
## How it works
AnonymousOverflow uses the existing question endpoint that StackOverflow uses. Simply replace the domain name in the URL with the domain name of the AnonymousOverflow instance you're using and you'll be able to view the question anonymously.
@@ -48,7 +71,11 @@ ${instanceURL}/questions/43743250/using-libsodium-xchacha20-poly1305-for-large-f
You can easily convert StackOverflow URLs to AnonymousOverflow ones by adding the following code as a bookmark in your web browser:
```js
javascript:(function() {window.location=window.location.toString().replace(/stackoverflow\.com/,'code.whatever.social');})()
javascript: (function () {
window.location = window.location
.toString()
.replace(/stackoverflow\.com/, "code.whatever.social");
})();
```
Replace `code.whatever.social` with the domain name of the instance you're using if needed.
@@ -57,7 +84,6 @@ You can run this bookmarklet on any StackOverflow page to view it anonymously.
Thanks to [Manav from ente.io](https://ente.io/about) for the handy tool.
## How to deploy
AnonymousOverflow uses Docker for deployment.
@@ -71,3 +97,7 @@ Run `docker compose up -d` to build and start the container.
## Attribution
- Icons provided by [heroicons](https://heroicons.com) under the [MIT License](https://choosealicense.com/licenses/mit/)
- [Gin](https://github.com/gin-gonic/gin) under the [MIT License](https://github.com/gin-gonic/gin/blob/master/LICENSE)
- [goquery](https://github.com/PuerkitoBio/goquery) under the [BSD 3-Clause License](https://github.com/PuerkitoBio/goquery/blob/master/LICENSE)
- [resty](https://github.com/go-resty/resty) under the [MIT License](https://github.com/go-resty/resty/blob/master/LICENSE)
- [Chroma](https://github.com/alecthomas/chroma) under the [MIT License](https://github.com/alecthomas/chroma/blob/master/COPYING)
+1 -1
View File
@@ -1,3 +1,3 @@
package config
var Version = "1.2"
var Version = "1.3.3"
+3
View File
@@ -3,9 +3,12 @@ package env
import (
"fmt"
"os"
"github.com/joho/godotenv"
)
func RunChecks() {
godotenv.Load(".env")
checkEnv("APP_URL")
}
+1
View File
@@ -17,6 +17,7 @@ require (
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator/v10 v10.11.1 // indirect
github.com/goccy/go-json v0.10.0 // indirect
github.com/joho/godotenv v1.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
+2
View File
@@ -31,6 +31,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+1
View File
@@ -35,6 +35,7 @@ func main() {
r.Static("/static", "./public")
r.Use(gin.Recovery())
r.Use(middleware.XssPreventionHeaders())
r.Use(middleware.NoCacheMiddleware())
r.Use(middleware.OptionsMiddleware())
r.Use(middleware.Ratelimit())
+4
View File
@@ -47,3 +47,7 @@ html {
border-radius: 50%;
padding: .25rem;
}
details {
cursor: pointer;
}
+20 -10
View File
@@ -25,13 +25,13 @@ body {
.view-form {
display: flex;
gap: .5rem;
gap: 0.5rem;
align-items: center;
}
.view-input {
width: 100%;
padding: .5rem;
padding: 0.5rem;
border: 2px solid transparent;
border-radius: 3px;
@@ -48,7 +48,7 @@ body {
}
.view-button {
padding: .5rem;
padding: 0.5rem;
border: 2px solid transparent;
border-radius: 3px;
@@ -65,29 +65,39 @@ body {
background-color: var(--input-bg-hover);
}
.error {
padding: .5rem;
background-color: rgb(255, 129, 129);
.success {
background-color: #8cffc0;
color: black;
}
.error {
background-color: rgb(255, 129, 129);
}
.error,
.success {
color: black;
padding: 0.5rem;
border-radius: 5px;
margin-bottom: 1rem;
}
.error p {
.error p,
.success p {
margin: 0;
}
.options {
margin-top: 1rem;
display: flex;
gap: .5rem;
gap: 0.5rem;
}
.title {
display: flex;
width: 100%;
align-items: center;
gap: .5rem;
gap: 0.5rem;
}
.logo {
@@ -100,4 +110,4 @@ body {
padding: 1rem;
box-sizing: border-box;
}
}
}
+41 -7
View File
@@ -8,7 +8,7 @@ body {
background-color: var(--main-bg);
color: var(--text-color);
font-family: sans-serif;
display: flex;
justify-content: center;
@@ -31,7 +31,7 @@ body {
code {
background-color: var(--code-bg);
padding: .15rem;
padding: 0.15rem;
border-radius: 5px;
color: white;
}
@@ -45,15 +45,17 @@ pre {
line-height: 1.35;
}
.timestamp {
color: var(--muted-text-color);
font-size: 0.8rem;
}
.answer-divider {
.post-divider, .answer-divider {
margin-top: 3rem;
margin-bottom: 3rem;
}
.answer-divider {
border: 0;
height: 1px;
background-color: var(--divider-color);
@@ -85,10 +87,10 @@ img {
.answer-author {
width: fit-content;
background-color: var(--meta-bg);
padding: .5rem;
padding: 0.5rem;
border-radius: 5px;
text-align: right;
font-size: .8rem;
font-size: 0.8rem;
}
.logo-link {
@@ -100,9 +102,41 @@ img {
height: 2rem;
}
.comments {
margin-top: 1rem;
}
.comments-parent {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.comment-parent {
cursor: text;
}
.comment-body {
font-size: 0.95rem;
}
.comment-author {
color: var(--muted-text-color);
font-size: 0.8rem;
}
.s-notice {
background-color: var(--meta-bg);
padding: 1rem;
border-radius: 5px;
color: var(--text-color);
margin-bottom: 1rem;
}
@media only screen and (max-width: 800px) {
body {
padding-left: 1rem;
padding-right: 1rem;
}
}
}
+16 -3
View File
@@ -1,6 +1,7 @@
package middleware
import (
"anonymousoverflow/config"
"sync"
"time"
@@ -31,14 +32,26 @@ func Ratelimit() gin.HandlerFunc {
// if they exceed 30 requests in 1 minute, return a 429
if val.(int) > 30 {
c.String(429, "429 Too Many Requests")
c.HTML(429, "home.html", gin.H{
"errorMessage": "You have exceeded the request limit. Please try again in a minute.",
"theme": c.MustGet("theme").(string),
"version": config.Version,
})
c.Abort()
return
}
// delete the ip from the map after 1 minute
// subtract 1 from the value after 1 minute if the value exists and is greater than 0
time.AfterFunc(time.Minute, func() {
ipMap.Delete(ip)
val, ok := ipMap.Load(ip)
if ok && val.(int) > 0 {
ipMap.Store(ip, val.(int)-1)
}
// if the value is 0, delete the entry from the map
if val.(int) == 0 {
ipMap.Delete(ip)
}
})
}
}
+12
View File
@@ -0,0 +1,12 @@
package middleware
import "github.com/gin-gonic/gin"
func XssPreventionHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("X-XSS-Protection", "1; mode=block")
c.Next()
}
}
+8 -3
View File
@@ -1,6 +1,7 @@
package routes
import (
"anonymousoverflow/config"
"fmt"
"os"
"strings"
@@ -18,7 +19,12 @@ func ChangeOptions(c *gin.Context) {
text = "enabled"
}
c.SetCookie("disable_images", fmt.Sprintf("%t", !c.MustGet("disable_images").(bool)), 60*60*24*365*10, "/", "", false, true)
c.String(200, "Images are now %s", text)
c.HTML(200, "home.html", gin.H{
"successMessage": "Images are now " + text,
"theme": c.MustGet("theme").(string),
"version": config.Version,
})
case "theme":
text := "dark"
if c.MustGet("theme").(string) == "dark" {
@@ -36,9 +42,8 @@ func ChangeOptions(c *gin.Context) {
}
c.Redirect(302, redirectUrl)
default:
c.String(400, "400 Bad Request")
}
c.Redirect(302, "/")
}
+102 -28
View File
@@ -1,13 +1,18 @@
package routes
import (
"anonymousoverflow/config"
"anonymousoverflow/src/utils"
"fmt"
"html"
"html/template"
"os"
"regexp"
"strconv"
"strings"
"anonymousoverflow/src/types"
"github.com/PuerkitoBio/goquery"
"github.com/gin-gonic/gin"
"github.com/go-resty/resty/v2"
@@ -20,13 +25,45 @@ func ViewQuestion(c *gin.Context) {
client := resty.New()
questionId := c.Param("id")
if len(questionId) < 5 {
c.HTML(400, "home.html", gin.H{
"errorMessage": "Invalid question ID",
"theme": c.MustGet("theme").(string),
"version": config.Version,
})
return
}
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
}
questionTitle := c.Param("title")
soLink := fmt.Sprintf("https://stackoverflow.com/questions/%s/%s", questionId, questionTitle)
resp, err := client.R().Get(soLink)
if err != nil {
panic(err)
c.HTML(500, "home.html", gin.H{
"errorMessage": "Unable to fetch question data",
"theme": c.MustGet("theme").(string),
"version": config.Version,
})
return
}
if resp.StatusCode() != 200 {
c.HTML(500, "home.html", gin.H{
"errorMessage": "Received a non-OK status code",
"theme": c.MustGet("theme").(string),
"version": config.Version,
})
return
}
respBody := resp.String()
@@ -35,20 +72,54 @@ func ViewQuestion(c *gin.Context) {
doc, err := goquery.NewDocumentFromReader(respBodyReader)
if err != nil {
panic(err)
c.HTML(500, "home.html", gin.H{
"errorMessage": "Unable to parse question data",
"theme": c.MustGet("theme").(string),
"version": config.Version,
})
return
}
newFilteredQuestion := types.FilteredQuestion{}
questionTextParent := doc.Find("h1.fs-headline1")
questionText := questionTextParent.Children().First().Text()
newFilteredQuestion.Title = questionText
questionPostLayout := doc.Find("div.post-layout").First()
questionBodyParent := doc.Find("div.s-prose")
questionBodyParentHTML, err := questionBodyParent.Html()
if err != nil {
panic(err)
c.HTML(500, "home.html", gin.H{
"errorMessage": "Unable to parse question body",
"theme": c.MustGet("theme").(string),
"version": config.Version,
})
return
}
newFilteredQuestion.Body = template.HTML(questionBodyParentHTML)
questionBodyText := questionBodyParent.Text()
// remove all whitespace to create the shortened body desc
shortenedBody := strings.TrimSpace(questionBodyText)
// remove all newlines
shortenedBody = strings.ReplaceAll(shortenedBody, "\n", " ")
// get the first 50 chars
shortenedBody = shortenedBody[:50]
newFilteredQuestion.ShortenedBody = shortenedBody
comments := utils.FindAndReturnComments(questionBodyParentHTML, questionPostLayout)
newFilteredQuestion.Comments = comments
// parse any code blocks and highlight them
answerCodeBlocks := questionCodeBlockRegex.FindAllString(questionBodyParentHTML, -1)
for _, codeBlock := range answerCodeBlocks {
@@ -82,6 +153,8 @@ func ViewQuestion(c *gin.Context) {
}
})
newFilteredQuestion.Timestamp = questionTimestamp
userDetails := questionMetadata.Find("div.user-details")
questionAuthor := ""
@@ -106,24 +179,24 @@ func ViewQuestion(c *gin.Context) {
}
})
answers := []template.HTML{}
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 := voteCell.Find("div.js-vote-count").Text()
voteCount := html.EscapeString(voteCell.Find("div.js-vote-count").Text())
if s.HasClass("accepted-answer") {
// add <div class="answer-meta accepted">Accepted Answer</div> to the top of the answer
answerBodyHTML = fmt.Sprintf(`<div class="answer-meta accepted">Accepted Answer - %s Upvotes</div>`, voteCount) + answerBodyHTML
} else {
// add <div class="answer-meta">%s Upvotes</div> to the top of the answer
answerBodyHTML = fmt.Sprintf(`<div class="answer-meta">%s Upvotes</div>`, voteCount) + answerBodyHTML
}
newFilteredAnswer.Upvotes = voteCount
newFilteredAnswer.IsAccepted = s.HasClass("accepted-answer")
answerFooter := s.Find("div.mt24")
@@ -146,13 +219,14 @@ func ViewQuestion(c *gin.Context) {
answerAuthor := answerAuthorDetails.Find("a").First()
answerAuthorURL = answerAuthor.AttrOr("href", "")
answerAuthorName = answerAuthor.Text()
answerTimestamp = s.Find("span.relativetime").Text()
answerAuthorURL = html.EscapeString(answerAuthor.AttrOr("href", ""))
answerAuthorName = html.EscapeString(answerAuthor.Text())
answerTimestamp = html.EscapeString(s.Find("span.relativetime").Text())
})
// append <div class="answer-author">Answered %s by %s</div> to the bottom of the answer
answerBodyHTML += fmt.Sprintf(`<div class="answer-author-parent"><div class="answer-author">Answered at %s by <a href="https://stackoverflow.com/%s" target="_blank" rel="noopener noreferrer">%s</a></div></div>`, answerTimestamp, answerAuthorURL, answerAuthorName)
newFilteredAnswer.AuthorName = answerAuthorName
newFilteredAnswer.AuthorURL = answerAuthorURL
newFilteredAnswer.Timestamp = answerTimestamp
// parse any code blocks and highlight them
answerCodeBlocks := codeBlockRegex.FindAllString(answerBodyHTML, -1)
@@ -166,7 +240,12 @@ func ViewQuestion(c *gin.Context) {
answerBodyHTML = strings.Replace(answerBodyHTML, codeBlock, highlightedCodeBlock, 1)
}
answers = append(answers, template.HTML(answerBodyHTML))
comments = utils.FindAndReturnComments(answerBodyHTML, postLayout)
newFilteredAnswer.Comments = comments
newFilteredAnswer.Body = template.HTML(answerBodyHTML)
answers = append(answers, newFilteredAnswer)
})
imagePolicy := "'self' https:"
@@ -176,16 +255,11 @@ func ViewQuestion(c *gin.Context) {
}
c.HTML(200, "question.html", gin.H{
"title": questionText,
"body": template.HTML(questionBodyParentHTML),
"timestamp": questionTimestamp,
"author": questionAuthor,
"authorURL": questionAuthorURL,
"answers": answers,
"imagePolicy": imagePolicy,
"shortenedBody": questionBodyParent.Text()[0:50],
"theme": c.MustGet("theme").(string),
"currentUrl": fmt.Sprintf("%s/questions/%s/%s", os.Getenv("APP_URL"), questionId, questionTitle),
"question": newFilteredQuestion,
"answers": answers,
"imagePolicy": imagePolicy,
"theme": c.MustGet("theme").(string),
"currentUrl": fmt.Sprintf("%s/questions/%s/%s", os.Getenv("APP_URL"), questionId, questionTitle),
})
}
+17
View File
@@ -0,0 +1,17 @@
package types
import "html/template"
type FilteredAnswer struct {
Upvotes string
IsAccepted bool
AuthorName string
AuthorURL string
Timestamp string
Body template.HTML
Comments []FilteredComment
}
+10
View File
@@ -0,0 +1,10 @@
package types
import "html/template"
type FilteredComment struct {
Text template.HTML
Timestamp string
AuthorName string
AuthorURL string
}
+13
View File
@@ -0,0 +1,13 @@
package types
import "html/template"
type FilteredQuestion struct {
Title string
Body template.HTML
Timestamp string
AuthorName string
AuthorURL string
ShortenedBody string
Comments []FilteredComment
}
+56
View File
@@ -0,0 +1,56 @@
package utils
import (
"anonymousoverflow/src/types"
"html/template"
"github.com/PuerkitoBio/goquery"
)
func FindAndReturnComments(inHtml string, postLayout *goquery.Selection) (comments []types.FilteredComment) {
commentsComponent := postLayout.Find("div.js-post-comments-component")
commentsList := commentsComponent.Find("div.comments")
commentsList2 := commentsList.Find("ul.comments-list")
allComments := commentsList2.Find("li.comment")
allComments.Each(func(i int, s *goquery.Selection) {
commentText := s.Find("div.comment-text")
commentBody := commentText.Find("div.comment-body")
commentCopy, err := commentBody.Find("span.comment-copy").Html()
if err != nil {
return
}
commentAuthorURL := ""
commentAuthor := commentBody.Find("span.comment-user")
if commentAuthor.Length() == 0 {
commentAuthor = commentBody.Find("a.comment-user")
if commentAuthor.Length() == 0 {
return
}
commentAuthorURL = commentAuthor.AttrOr("href", "")
}
commentTimestamp := commentBody.Find("span.relativetime-clean").Text()
newFilteredComment := types.FilteredComment{
Text: template.HTML(commentCopy),
Timestamp: commentTimestamp,
AuthorName: commentAuthor.Text(),
AuthorURL: commentAuthorURL,
}
comments = append(comments, newFilteredComment)
})
return
}
+3 -15
View File
@@ -7,17 +7,15 @@ import (
"regexp"
"strings"
"github.com/alecthomas/chroma/formatters"
html_formatter "github.com/alecthomas/chroma/formatters/html"
"github.com/alecthomas/chroma/lexers"
"github.com/alecthomas/chroma/styles"
)
var plainFormattedCodeRegex = regexp.MustCompile(`(?s)<pre tabindex="0" class="chroma"><code>(.+?)</code></pre>`)
func HighlightSyntaxViaContent(content string) (htmlOut string) {
content = html.UnescapeString(content)
fallbackOut := content
fallbackOut := html.EscapeString(content)
// identify the language
lexer := lexers.Analyse(content)
@@ -32,10 +30,7 @@ func HighlightSyntaxViaContent(content string) (htmlOut string) {
style = styles.Fallback
}
formatter := formatters.Get("html")
if formatter == nil {
formatter = formatters.Fallback
}
formatter := html_formatter.New(html_formatter.PreventSurroundingPre(true), html_formatter.WithClasses(true))
iterator, err := lexer.Tokenise(nil, content)
if err != nil {
@@ -52,14 +47,7 @@ func HighlightSyntaxViaContent(content string) (htmlOut string) {
return
}
// parse only the <pre><code>...</code></pre> part
htmlOut = b.String()
htmlOut = plainFormattedCodeRegex.FindString(htmlOut)
htmlOut = StripBlockTags(htmlOut)
// remove <pre tabindex="0" class="chroma">
htmlOut = strings.Replace(htmlOut, "<pre tabindex=\"0\" class=\"chroma\">", "", -1)
return
}
+25
View File
@@ -0,0 +1,25 @@
<details class="comments">
<summary>
Show <b>{{ (len .Comments) }} comments</b>
</summary>
<div class="comments-parent">
{{ range $comment := .Comments }}
<div class="comment-parent">
<div class="comment">
<div class="comment-body">
{{ $comment.Text }}
</div>
<div class="comment-author">
Commented {{ $comment.Timestamp }} by
<a
href="https://stackoverflow.com{{ $comment.AuthorURL }}"
target="_blank"
rel="noopener noreferrer"
>{{ $comment.AuthorName }}</a
>.
</div>
</div>
</div>
{{end}}
</div>
</details>
+9 -13
View File
@@ -2,20 +2,17 @@
<html data-theme="{{ .theme }}">
<head>
<title>AnonymousOverflow - Private frontend for StackOverflow</title>
<link rel="stylesheet" href="/static/globals.css" />
<link rel="stylesheet" href="/static/home.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'self'; script-src 'none'; img-src 'self';"
/>
<link rel="icon" href="/static/codecircles.png" />
<meta name="theme-color" content="#8CFFC1" />
<meta name="og:image" content="/static/codecircles.png" />
<meta
name="description"
content="View StackOverflow threads in privacy and without the clutter."
/>
{{ template "sharedHead.html" }}
</head>
<body>
<div class="container">
@@ -34,12 +31,16 @@
browsing habits and other browser fingerprint data to
StackOverflow.
</p>
{{ if .errorMessage }}
{{ if .successMessage }}
<div class="success">
<p><b>Success</b>: {{ .successMessage }}</p>
</div>
{{ else}} {{ if .errorMessage }}
<div class="error">
<p><b>Error</b>: {{ .errorMessage }}</p>
</div>
{{ end }}
<form method="POST">
{{end}} {{ end }}
<form method="POST" action="/">
<div class="view-form">
<input
class="view-input"
@@ -56,12 +57,7 @@
<img src="/static/icons/image.svg" alt="Toggle theme" />
</a>
</div>
<div class="icon">
<a href="/options/theme?redirect_url={{ .currentUrl }}">
<img src="/static/icons/{{ if eq .theme "dark" }}sun{{
else }}moon{{ end }}.svg" alt="Toggle theme" />
</a>
</div>
{{ template "themeSwitcher.html" .}}
</div>
<p class="footer">
Brought to you by
+37 -20
View File
@@ -1,19 +1,15 @@
<!DOCTYPE html>
<html data-theme="{{ .theme }}">
<head>
<title>{{ .title }}</title>
<title>{{ .question.Title }}</title>
<link rel="stylesheet" href="/static/question.css" />
<link rel="stylesheet" href="/static/globals.css" />
<link rel="stylesheet" href="/static/syntax.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'self'; script-src 'none'; img-src {{ .imagePolicy }};"
/>
<link rel="icon" href="/static/codecircles.png" />
<meta name="theme-color" content="#8CFFC1" />
<meta name="og:image" content="/static/codecircles.png" />
<meta name="description" content="{{ .shortenedBody }}..." />
<meta name="description" content="{{ .question.ShortenedBody }}..." />
{{ template "sharedHead.html" }}
</head>
<body>
<div class="parent">
@@ -25,32 +21,53 @@
alt="4 circles with alternating colors between green and white"
/>
</a>
<div class="icon">
<a href="/options/theme?redirect_url={{ .currentUrl }}">
<img src="/static/icons/{{ if eq .theme "dark" }}sun{{
else }}moon{{ end }}.svg" alt="Toggle theme" />
</a>
</div>
{{ template "themeSwitcher.html" .}}
</div>
<div class="card">
<div class="card-header">
<h1>{{ .title }}</h1>
<h1>{{ .question.Title }}</h1>
<p class="timestamp">
Asked {{ .timestamp }} by
Asked {{ .question.Timestamp }} by
<a
href="https://stackoverflow.com{{ .authorURL }}"
href="https://stackoverflow.com{{ .question.AuthorURL }}"
target="_blank"
rel="noopener noreferrer"
>{{ .author }}</a
>{{ .question.AuthorName }}</a
>.
</p>
</div>
<div class="card-body">{{ .body }}</div>
<div class="card-body">{{ .question.Body }}</div>
{{ if .question.Comments }}
{{ template "comments.html" .question }}
{{end}}
</div>
<hr />
<hr class="post-divider" />
<h2>Answers</h2>
{{ range $answer := .answers }}
<div class="answer">{{ $answer }}</div>
<div class="answer">
<div
class="answer-meta{{ if $answer.IsAccepted }} accepted{{end}}"
>
{{ if $answer.IsAccepted }} Accepted - {{ end }}
{{$answer.Upvotes}} Votes
</div>
{{ $answer.Body }}
<div class="answer-author-parent">
<div class="answer-author">
Answered {{ $answer.Timestamp }} by
<a
href="https://stackoverflow.com{{ $answer.AuthorURL }}"
target="_blank"
rel="noopener noreferrer"
>{{ $answer.AuthorName }}</a
>
</div>
</div>
{{ if $answer.Comments }}
{{ template "comments.html" $answer }}
{{end}}
</div>
<hr class="answer-divider" />
{{ end }}
</div>
+4
View File
@@ -0,0 +1,4 @@
<meta name="theme-color" content="#8CFFC1" />
<meta name="og:image" content="/static/codecircles.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/globals.css" />
+6
View File
@@ -0,0 +1,6 @@
<div class="icon">
<a href="/options/theme?redirect_url={{ .currentUrl }}">
<img src="/static/icons/{{ if eq .theme "dark" }}sun{{
else }}moon{{ end }}.svg" alt="Toggle theme" />
</a>
</div>