mirror of
https://github.com/httpjamesm/AnonymousOverflow.git
synced 2024-12-06 19:16:43 +01:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15b843d46b | |||
| 7b80c55dd2 | |||
| 12174b2600 | |||
| 5f11bcd6a2 | |||
| 7494b2df33 | |||
| 517a9afb89 | |||
| f9e42ca8c7 | |||
| 3aac1467cd | |||
| 062ffbcbdc | |||
| aa13c8515f | |||
| d49c0f2ffd | |||
| 89d8dee958 | |||
| 0ad3f08ef5 | |||
| d5bcf7fa38 | |||
| 95df8a715b | |||
| f2b369b2c6 | |||
| 17a8c6b1e1 | |||
| be9380ba20 | |||
| e4d429b642 | |||
| b35c75cdf1 | |||
| c46820b6d4 | |||
| 0e9677ec61 | |||
| 4ddc1a2af7 | |||
| e476087a3a | |||
| 3ff8e8cb96 | |||
| 1d12372ea5 | |||
| b038d2c502 | |||
| e6915ae849 | |||
| 572fac9ec3 | |||
| 11efaf854d | |||
| 3b79efc83c | |||
| c0df07abad | |||
| 10e7c89e96 | |||
| 6f86cbce6e | |||
| 1d2e98e4c1 | |||
| 599d23544b | |||
| 84ba0886cd | |||
| 5bd9ce484f | |||
| 8f9091fb2a | |||
| 1b1120d924 | |||
| a327366400 | |||
| 97e2e50015 | |||
| 96d4f768d9 | |||
| 14b11b7f14 | |||
| 631a700c9f | |||
| da57f98fec | |||
| b68786a50f | |||
| 819a8fe39f | |||
| 0d5f0a04f1 | |||
| 46545385e1 | |||
| ef723a0c21 | |||
| 0e2e8b26f6 | |||
| 5a1e1ca652 | |||
| 5373b9d979 | |||
| a3b69f37a2 | |||
| f9025ff9cc |
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: 'Create a report to help us improve'
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Please make sure you're on the latest version before submitting.
|
||||
|
||||
|
||||
# What's Happening?
|
||||
<!-- Describe here -->
|
||||
|
||||
|
||||
## How to reproduce:
|
||||
<!-- Describe here -->
|
||||
|
||||
|
||||
## Affected Platforms:
|
||||
|
||||
- [ ] macOS
|
||||
- [ ] Windows
|
||||
- [ ] Linux (Specify)
|
||||
- [ ] iOS
|
||||
- [ ] Android
|
||||
|
||||
Version:
|
||||
|
||||
## Browser:
|
||||
|
||||
- [ ] Chromium-based (ex: Brave or Chrome)
|
||||
- [ ] Webkit-based (ex: Safari)
|
||||
- [ ] Gecko-based (ex: Firefox)
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: New Instance
|
||||
about: 'Add my public instance to the list'
|
||||
title: "[INSTANCE] New public instance"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Instance URL:
|
||||
Region (Written Out - ex United States):
|
||||
Operated by (Link to your site):
|
||||
+3
-1
@@ -1,2 +1,4 @@
|
||||
.env
|
||||
docker-compose.yml
|
||||
docker-compose.yml
|
||||
.DS_Store
|
||||
*bin
|
||||
Vendored
+15
@@ -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": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,11 +12,47 @@ This project is super lightweight by design. The UI is simple and the frontend i
|
||||
|
||||

|
||||
|
||||
## Instances
|
||||
## Clearnet Instances
|
||||
|
||||
| Instance URL | Region | Notes |
|
||||
|------------------------------------------------------|-------------------------|--------------------------------------------------------------------------------------------------|
|
||||
| [code.whatever.social](https://code.whatever.social) | United States & Germany | Operated by [Whatever Social](https://whatever.social) and [http.james](https://httpjames.space) |
|
||||
| Instance URL | Region | Notes |
|
||||
| ------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| [code.whatever.social](https://code.whatever.social) | United States & Germany | Operated by [Whatever Social](https://whatever.social) and [http.james](https://httpjames.space) |
|
||||
| [overflow.777.tf](https://overflow.777.tf/) | The Netherlands | Operated by [Jae](https://777.tf) |
|
||||
| [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) |
|
||||
| [anonymousoverflow.esmailelbob.xyz](https://anonymousoverflow.esmailelbob.xyz/) | Canada | Operated by [Esmail EL BoB](https://esmailelbob.xyz) |
|
||||
| [overflow.lunar.icu](https://overflow.lunar.icu) | Germany | Operated by [lunar.icu](https://lunar.icu/) |
|
||||
|
||||
## Other Instances
|
||||
|
||||
| Instance URL | Region | Notes |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ---------------------------------------------------- |
|
||||
| [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) |
|
||||
| [anonymousoverflow.esmail5pdn24shtvieloeedh7ehz3nrwcdivnfhfcedl7gf4kwddhkqd.onion](http://anonymousoverflow.esmail5pdn24shtvieloeedh7ehz3nrwcdivnfhfcedl7gf4kwddhkqd.onion) | Canada | Operated by [Esmail EL BoB](https://esmailelbob.xyz) |
|
||||
|
||||
## 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
|
||||
|
||||
@@ -34,16 +70,32 @@ becomes
|
||||
${instanceURL}/questions/43743250/using-libsodium-xchacha20-poly1305-for-large-files
|
||||
```
|
||||
|
||||
### Bookmark Conversion Tool
|
||||
|
||||
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");
|
||||
})();
|
||||
```
|
||||
|
||||
Replace `code.whatever.social` with the domain name of the instance you're using if needed.
|
||||
|
||||
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.
|
||||
|
||||
Install Docker for your platform and copy the `docker-compose.example.yml` file to `docker-compose.yml`.
|
||||
|
||||
Then, tweak settings if needed.
|
||||
|
||||
Run `docker compose up -d` to build and start the container.
|
||||
Read the [wiki page](https://github.com/httpjamesm/AnonymousOverflow/wiki/Deployment).
|
||||
|
||||
## Attribution
|
||||
|
||||
- Icons provided by [heroicons](https://heroicons.com) under the [MIT License](https://choosealicense.com/licenses/mit/)
|
||||
- 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
@@ -1,3 +1,3 @@
|
||||
package config
|
||||
|
||||
var Version = "1.1"
|
||||
var Version = "1.5.1"
|
||||
|
||||
@@ -8,6 +8,7 @@ services:
|
||||
network: 'host'
|
||||
environment:
|
||||
- APP_URL=https://domain.com
|
||||
- JWT_SIGNING_SECRET=secret
|
||||
ports:
|
||||
- '80:8080'
|
||||
restart: 'always'
|
||||
Vendored
+4
@@ -3,10 +3,14 @@ package env
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func RunChecks() {
|
||||
godotenv.Load(".env")
|
||||
checkEnv("APP_URL")
|
||||
checkEnv("JWT_SIGNING_SECRET")
|
||||
}
|
||||
|
||||
func checkEnv(key string) {
|
||||
|
||||
@@ -3,15 +3,22 @@ module anonymousoverflow
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.8.0
|
||||
github.com/alecthomas/chroma v0.10.0
|
||||
github.com/gin-gonic/gin v1.8.2
|
||||
github.com/go-resty/resty/v2 v2.7.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.7.0 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.8.2 // indirect
|
||||
github.com/go-playground/locales v0.14.0 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.11.1 // indirect
|
||||
github.com/go-resty/resty/v2 v2.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.0 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3 // 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
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
|
||||
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
|
||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.8.2 h1:UzKToD9/PoFj/V4rvlKqTRKnQYyz8Sc1MJlv4JHPtvY=
|
||||
github.com/gin-gonic/gin v1.8.2/go.mod h1:qw5AYuDrzRTnhvusDsrov+fDIxp9Dleuu12h8nfB398=
|
||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
|
||||
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||
@@ -20,16 +27,23 @@ github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPr
|
||||
github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I=
|
||||
github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
|
||||
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
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=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
@@ -43,8 +57,10 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
|
||||
github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
|
||||
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
@@ -54,6 +70,7 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/ugorji/go/codec v1.2.8 h1:sgBJS6COt0b/P40VouWKdseidkDgHxYGm0SAglUHfP0=
|
||||
github.com/ugorji/go/codec v1.2.8/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
@@ -61,7 +78,6 @@ golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0
|
||||
golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
|
||||
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211029224645-99673261e6eb h1:pirldcYWx7rx7kE5r+9WsOXPXK0+WH5+uZ7uPmJ44uM=
|
||||
golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
|
||||
@@ -79,16 +95,19 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
|
||||
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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=
|
||||
|
||||
@@ -35,6 +35,8 @@ 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())
|
||||
|
||||
@@ -50,5 +52,7 @@ func main() {
|
||||
|
||||
r.GET("/questions/:id/:title", routes.ViewQuestion)
|
||||
|
||||
r.GET("/proxy", routes.GetImage)
|
||||
|
||||
r.Run(fmt.Sprintf("%s:%s", host, port))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
.comments {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.comments-parent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.comment-parent {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.comment-top {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.comment-score {
|
||||
border-right: 1px solid white;
|
||||
padding-right: .5rem;
|
||||
}
|
||||
|
||||
.comment-body {
|
||||
font-size: 0.95rem;
|
||||
padding-left: .5rem;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
color: var(--muted-text-color);
|
||||
font-size: 0.8rem;
|
||||
padding-left: 1.5rem;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
+5
-1
@@ -14,7 +14,7 @@
|
||||
--main-bg: rgb(219, 219, 219);
|
||||
--text-color: #000;
|
||||
--muted-text-color: #636363;
|
||||
--code-bg: #b5b5b5;
|
||||
--code-bg: #36383d;
|
||||
--input-bg: rgb(188, 188, 188);
|
||||
--input-bg-hover: rgb(168, 168, 168);
|
||||
--meta-bg: rgb(170, 168, 168);
|
||||
@@ -47,3 +47,7 @@ html {
|
||||
border-radius: 50%;
|
||||
padding: .25rem;
|
||||
}
|
||||
|
||||
details {
|
||||
cursor: pointer;
|
||||
}
|
||||
+20
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-7
@@ -8,7 +8,7 @@ body {
|
||||
background-color: var(--main-bg);
|
||||
color: var(--text-color);
|
||||
font-family: sans-serif;
|
||||
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -31,9 +31,9 @@ body {
|
||||
|
||||
code {
|
||||
background-color: var(--code-bg);
|
||||
padding: .15rem;
|
||||
padding: 0.15rem;
|
||||
border-radius: 5px;
|
||||
color: var(--text-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
pre {
|
||||
@@ -45,15 +45,18 @@ pre {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
|
||||
.timestamp {
|
||||
color: var(--muted-text-color);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.post-divider,
|
||||
.answer-divider {
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.answer-divider {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background-color: var(--divider-color);
|
||||
@@ -85,10 +88,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 +103,17 @@ img {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Background */ .bg { color: #f8f8f2; background-color: #272822; }
|
||||
/* Error */ .err { color: #960050; background-color: #1e0010 }
|
||||
/* LineTableTD */ .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
|
||||
/* LineTable */ .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
|
||||
/* LineHighlight */ .hl { background-color: #3c3d38 }
|
||||
/* LineNumbersTable */ .lnt { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
|
||||
/* LineNumbers */ .ln { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
|
||||
/* Line */ .line { display: flex; }
|
||||
/* Keyword */ .k { color: #66d9ef }
|
||||
/* KeywordConstant */ .kc { color: #66d9ef }
|
||||
/* KeywordDeclaration */ .kd { color: #66d9ef }
|
||||
/* KeywordNamespace */ .kn { color: #f92672 }
|
||||
/* KeywordPseudo */ .kp { color: #66d9ef }
|
||||
/* KeywordReserved */ .kr { color: #66d9ef }
|
||||
/* KeywordType */ .kt { color: #66d9ef }
|
||||
/* NameAttribute */ .na { color: #a6e22e }
|
||||
/* NameClass */ .nc { color: #a6e22e }
|
||||
/* NameConstant */ .no { color: #66d9ef }
|
||||
/* NameDecorator */ .nd { color: #a6e22e }
|
||||
/* NameException */ .ne { color: #a6e22e }
|
||||
/* NameFunction */ .nf { color: #a6e22e }
|
||||
/* NameOther */ .nx { color: #a6e22e }
|
||||
/* NameTag */ .nt { color: #f92672 }
|
||||
/* Literal */ .l { color: #ae81ff }
|
||||
/* LiteralDate */ .ld { color: #e6db74 }
|
||||
/* LiteralString */ .s { color: #e6db74 }
|
||||
/* LiteralStringAffix */ .sa { color: #e6db74 }
|
||||
/* LiteralStringBacktick */ .sb { color: #e6db74 }
|
||||
/* LiteralStringChar */ .sc { color: #e6db74 }
|
||||
/* LiteralStringDelimiter */ .dl { color: #e6db74 }
|
||||
/* LiteralStringDoc */ .sd { color: #e6db74 }
|
||||
/* LiteralStringDouble */ .s2 { color: #e6db74 }
|
||||
/* LiteralStringEscape */ .se { color: #ae81ff }
|
||||
/* LiteralStringHeredoc */ .sh { color: #e6db74 }
|
||||
/* LiteralStringInterpol */ .si { color: #e6db74 }
|
||||
/* LiteralStringOther */ .sx { color: #e6db74 }
|
||||
/* LiteralStringRegex */ .sr { color: #e6db74 }
|
||||
/* LiteralStringSingle */ .s1 { color: #e6db74 }
|
||||
/* LiteralStringSymbol */ .ss { color: #e6db74 }
|
||||
/* LiteralNumber */ .m { color: #ae81ff }
|
||||
/* LiteralNumberBin */ .mb { color: #ae81ff }
|
||||
/* LiteralNumberFloat */ .mf { color: #ae81ff }
|
||||
/* LiteralNumberHex */ .mh { color: #ae81ff }
|
||||
/* LiteralNumberInteger */ .mi { color: #ae81ff }
|
||||
/* LiteralNumberIntegerLong */ .il { color: #ae81ff }
|
||||
/* LiteralNumberOct */ .mo { color: #ae81ff }
|
||||
/* Operator */ .o { color: #f92672 }
|
||||
/* OperatorWord */ .ow { color: #f92672 }
|
||||
/* Comment */ .c { color: #75715e }
|
||||
/* CommentHashbang */ .ch { color: #75715e }
|
||||
/* CommentMultiline */ .cm { color: #75715e }
|
||||
/* CommentSingle */ .c1 { color: #75715e }
|
||||
/* CommentSpecial */ .cs { color: #75715e }
|
||||
/* CommentPreproc */ .cp { color: #75715e }
|
||||
/* CommentPreprocFile */ .cpf { color: #75715e }
|
||||
/* GenericDeleted */ .gd { color: #f92672 }
|
||||
/* GenericEmph */ .ge { font-style: italic }
|
||||
/* GenericInserted */ .gi { color: #a6e22e }
|
||||
/* GenericStrong */ .gs { font-weight: bold }
|
||||
/* GenericSubheading */ .gu { color: #75715e }
|
||||
@@ -0,0 +1,12 @@
|
||||
package middleware
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func NoCacheMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Header("Expires", "0")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"anonymousoverflow/src/types"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
func GetImage(c *gin.Context) {
|
||||
|
||||
authorization := c.Query("auth")
|
||||
if authorization == "" {
|
||||
c.String(400, "Missing auth token")
|
||||
return
|
||||
}
|
||||
|
||||
// validate the auth token
|
||||
token, err := jwt.ParseWithClaims(authorization, &types.ImageProxyClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
|
||||
// validate the signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
return []byte(os.Getenv("JWT_SIGNING_SECRET")), nil
|
||||
})
|
||||
if err != nil {
|
||||
c.String(400, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*types.ImageProxyClaims)
|
||||
if !ok || !token.Valid {
|
||||
c.String(400, "Invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
if claims.Action != "imageProxy" {
|
||||
c.String(400, "Invalid action")
|
||||
return
|
||||
}
|
||||
|
||||
if claims.Exp < time.Now().Unix() {
|
||||
c.String(400, "Token expired")
|
||||
return
|
||||
}
|
||||
|
||||
// download the image
|
||||
client := resty.New()
|
||||
resp, err := client.R().Get(claims.ImageURL)
|
||||
if err != nil {
|
||||
c.AbortWithStatus(500)
|
||||
return
|
||||
}
|
||||
|
||||
// set the content type
|
||||
c.Header("Content-Type", resp.Header().Get("Content-Type"))
|
||||
|
||||
// write the image to the response
|
||||
c.Writer.Write(resp.Body())
|
||||
}
|
||||
@@ -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, "/")
|
||||
}
|
||||
|
||||
+121
-29
@@ -1,27 +1,60 @@
|
||||
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"
|
||||
)
|
||||
|
||||
var codeBlockRegex = regexp.MustCompile(`(?s)<pre><code>(.+?)<\/code><\/pre>`)
|
||||
var questionCodeBlockRegex = regexp.MustCompile(`(?s)<pre class=".+"><code( class=".+")?>(.+?)</code></pre>`)
|
||||
|
||||
func ViewQuestion(c *gin.Context) {
|
||||
client := resty.New()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -30,18 +63,64 @@ 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(utils.ReplaceImgTags(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 {
|
||||
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")
|
||||
@@ -65,6 +144,8 @@ func ViewQuestion(c *gin.Context) {
|
||||
}
|
||||
})
|
||||
|
||||
newFilteredQuestion.Timestamp = questionTimestamp
|
||||
|
||||
userDetails := questionMetadata.Find("div.user-details")
|
||||
|
||||
questionAuthor := ""
|
||||
@@ -89,24 +170,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")
|
||||
|
||||
@@ -129,17 +210,33 @@ 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
|
||||
|
||||
// get the timestamp and author
|
||||
// parse any code blocks and highlight them
|
||||
answerCodeBlocks := codeBlockRegex.FindAllString(answerBodyHTML, -1)
|
||||
for _, codeBlock := range answerCodeBlocks {
|
||||
codeBlock = utils.StripBlockTags(codeBlock)
|
||||
|
||||
answers = append(answers, template.HTML(answerBodyHTML))
|
||||
// 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, postLayout)
|
||||
|
||||
newFilteredAnswer.Comments = comments
|
||||
newFilteredAnswer.Body = template.HTML(utils.ReplaceImgTags(answerBodyHTML))
|
||||
|
||||
answers = append(answers, newFilteredAnswer)
|
||||
})
|
||||
|
||||
imagePolicy := "'self' https:"
|
||||
@@ -149,16 +246,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),
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package types
|
||||
|
||||
import "html/template"
|
||||
|
||||
type FilteredComment struct {
|
||||
Text template.HTML
|
||||
Timestamp string
|
||||
AuthorName string
|
||||
AuthorURL string
|
||||
Upvotes string
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package types
|
||||
|
||||
import "github.com/golang-jwt/jwt/v4"
|
||||
|
||||
type ImageProxyClaims struct {
|
||||
Action string `json:"action"`
|
||||
|
||||
ImageURL string `json:"image_url"`
|
||||
|
||||
Iss int64 `json:"iss"`
|
||||
Exp int64 `json:"exp"`
|
||||
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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) {
|
||||
commentScoreParent := s.Find("div.comment-score")
|
||||
|
||||
// find the first span within commentScoreParent
|
||||
commentScore := commentScoreParent.Find("span").First().Text()
|
||||
if commentScore == "" {
|
||||
commentScore = "0"
|
||||
}
|
||||
|
||||
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,
|
||||
Upvotes: commentScore,
|
||||
}
|
||||
|
||||
comments = append(comments, newFilteredComment)
|
||||
|
||||
})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"anonymousoverflow/src/types"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
var imgTagRegex = regexp.MustCompile(`<img[^>]*\s+src\s*=\s*"(.*?)"[^>]*>`)
|
||||
|
||||
func ReplaceImgTags(inHtml string) string {
|
||||
// find all img tags
|
||||
imgTags := imgTagRegex.FindAllString(inHtml, -1)
|
||||
|
||||
for _, imgTag := range imgTags {
|
||||
// parse the src="" attribute
|
||||
srcRegex := regexp.MustCompile(`src\s*=\s*"(.*?)"`)
|
||||
src := srcRegex.FindStringSubmatch(imgTag)[1]
|
||||
|
||||
authToken, _ := generateImageProxyAuth(src)
|
||||
|
||||
// replace the img tag with a proxied url
|
||||
inHtml = strings.Replace(inHtml, imgTag, fmt.Sprintf(`<img src="%s/proxy?auth=%s">`, os.Getenv("APP_URL"), authToken), 1)
|
||||
}
|
||||
|
||||
return inHtml
|
||||
}
|
||||
|
||||
func generateImageProxyAuth(url string) (string, error) {
|
||||
// generate a jwt with types.ImageProxyClaims
|
||||
claims := types.ImageProxyClaims{
|
||||
Action: "imageProxy",
|
||||
ImageURL: url,
|
||||
Iss: time.Now().Unix(),
|
||||
Exp: time.Now().Add(time.Minute).Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS512, claims)
|
||||
|
||||
// sign the token
|
||||
ss, err := token.SignedString([]byte(os.Getenv("JWT_SIGNING_SECRET")))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return ss, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
html_formatter "github.com/alecthomas/chroma/formatters/html"
|
||||
"github.com/alecthomas/chroma/lexers"
|
||||
"github.com/alecthomas/chroma/styles"
|
||||
)
|
||||
|
||||
func HighlightSyntaxViaContent(content string) (htmlOut string) {
|
||||
content = html.UnescapeString(content)
|
||||
|
||||
fallbackOut := html.EscapeString(content)
|
||||
|
||||
// identify the language
|
||||
lexer := lexers.Analyse(content)
|
||||
if lexer == nil {
|
||||
// unable to identify, so just return the wrapped content
|
||||
htmlOut = fallbackOut
|
||||
return
|
||||
}
|
||||
|
||||
style := styles.Get("xcode")
|
||||
if style == nil {
|
||||
style = styles.Fallback
|
||||
}
|
||||
|
||||
formatter := html_formatter.New(html_formatter.PreventSurroundingPre(true), html_formatter.WithClasses(true))
|
||||
|
||||
iterator, err := lexer.Tokenise(nil, content)
|
||||
if err != nil {
|
||||
htmlOut = fallbackOut
|
||||
return
|
||||
}
|
||||
|
||||
b := bytes.NewBufferString("")
|
||||
w := io.Writer(b)
|
||||
|
||||
err = formatter.Format(w, style, iterator)
|
||||
if err != nil {
|
||||
htmlOut = fallbackOut
|
||||
return
|
||||
}
|
||||
|
||||
htmlOut = b.String()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var preClassRegex = regexp.MustCompile(`(?s)<pre class=".+">`)
|
||||
|
||||
func StripBlockTags(content string) (result string) {
|
||||
// strip all "<code>" tags
|
||||
content = strings.Replace(content, "<code>", "", -1)
|
||||
content = strings.Replace(content, "</code>", "", -1)
|
||||
// and the <pre>
|
||||
content = strings.Replace(content, "<pre>", "", -1)
|
||||
content = strings.Replace(content, "</pre>", "", -1)
|
||||
|
||||
content = preClassRegex.ReplaceAllString(content, "")
|
||||
|
||||
result = content
|
||||
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<details class="comments">
|
||||
<summary>
|
||||
Show
|
||||
<b
|
||||
>{{ (len .Comments) }} comment{{ if gt (len .Comments) 1 }}s{{
|
||||
end }}</b
|
||||
>
|
||||
</summary>
|
||||
<div class="comments-parent">
|
||||
{{ range $comment := .Comments }}
|
||||
<div class="comment-parent">
|
||||
<div class="comment">
|
||||
<div class="comment-top">
|
||||
<div class="comment-score" title="Comment Upvotes">
|
||||
{{ $comment.Upvotes }}
|
||||
</div>
|
||||
<div class="comment-body">{{ $comment.Text }}</div>
|
||||
</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
@@ -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
|
||||
|
||||
+39
-20
@@ -1,18 +1,16 @@
|
||||
<!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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/static/syntax.css" />
|
||||
<link rel="stylesheet" href="/static/comments.css" />
|
||||
<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">
|
||||
@@ -24,32 +22,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>
|
||||
|
||||
@@ -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" />
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user