mirror of
https://github.com/httpjamesm/AnonymousOverflow.git
synced 2024-12-06 19:16:43 +01:00
Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72a7392142 | |||
| 4760681c55 | |||
| 6495e4a4bd | |||
| 1f77bf4ed6 | |||
| 9a6a044d20 | |||
| 7201269609 | |||
| a666e02256 | |||
| 50fc162b47 | |||
| 2fe61471fb | |||
| 4e8a7d3553 | |||
| 2bbed5606d | |||
| dbbc9570db | |||
| 37d28dd704 | |||
| 236eb849e4 | |||
| ee5f4eb4b6 | |||
| 975ed51726 | |||
| 5b8e6acf9e | |||
| 92b8707882 | |||
| c95c2d74b3 | |||
| 296b30323a | |||
| 225420029e | |||
| 6736b6440c | |||
| 7dc9856643 | |||
| 5bc43af96d | |||
| 2afecd8ae7 | |||
| bdca47a51e | |||
| 51190f8d69 | |||
| bfa19bc2d2 | |||
| 55b06b986b | |||
| 09de9f11a5 | |||
| 63e9792d2a | |||
| 8ee1d76993 | |||
| 6b77c5dae4 | |||
| df93607e5d | |||
| 05fbd9608e | |||
| 66db070fa5 | |||
| bb9f8e2aa2 | |||
| 487a62e7aa | |||
| abfd0cfb6f | |||
| 63a30ab1cf | |||
| f1370c29b6 | |||
| d935ce72f6 | |||
| 45644a0503 | |||
| f81b726612 | |||
| c55b8b6ed5 | |||
| ba55578143 | |||
| 95e270d9f3 | |||
| 0968932eff | |||
| 64302c9334 | |||
| 9d28ee483c | |||
| 4fa89879cf | |||
| 203e8aa23c | |||
| cc9e549aaf | |||
| fae2717a35 | |||
| cf8892b4cb | |||
| c8a394e0ce | |||
| 6bfed5a9e3 | |||
| a0df61a92d | |||
| bbf098e7f4 | |||
| 2c4ded0800 | |||
| f0e42287cf | |||
| 9ebb6c90d9 | |||
| 69d96d646d | |||
| c87ce26da8 | |||
| 3dea27b09f | |||
| 4d8632e26c | |||
| eee8ec42c5 | |||
| d7bca93af8 | |||
| 2b608b4725 | |||
| bd48b309ac | |||
| 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 |
@@ -0,0 +1,44 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ."
|
||||
delay = 0
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
+4
-1
@@ -1,2 +1,5 @@
|
||||
.env
|
||||
docker-compose.yml
|
||||
docker-compose.yml
|
||||
.DS_Store
|
||||
*bin
|
||||
/tmp
|
||||
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": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,26 +6,70 @@ This project is super lightweight by design. The UI is simple and the frontend i
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## 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) |
|
||||
| [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) |
|
||||
| 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) |
|
||||
| [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.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.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/) |
|
||||
| [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/) |
|
||||
| [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/) |
|
||||
| [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) |
|
||||
| | | |
|
||||
|
||||
## 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) |
|
||||
| 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) |
|
||||
| [overflow.pjsfkvpxlinjamtawaksbnnaqs2fc2mtvmozrzckxh7f3kis6yea25ad.onion](http://overflow.pjsfkvpxlinjamtawaksbnnaqs2fc2mtvmozrzckxh7f3kis6yea25ad.onion/) | Luxembourg | Operated by [Project Segfault](https://projectsegfau.lt/) |
|
||||
| [overflow.daturab6drmkhyeia4ch5gvfc2f3wgo6bhjrv3pz6n7kxmvoznlkq4yd.onion](http://overflow.daturab6drmkhyeia4ch5gvfc2f3wgo6bhjrv3pz6n7kxmvoznlkq4yd.onion/) | Germany | Operated by [datura.network](https://datura.network) |
|
||||
| [ao.pk47sgwhncn5cgidm7bofngmh7lc7ukjdpk5bjwfemmyp27ovl25ikyd.onion](http://ao.pk47sgwhncn5cgidm7bofngmh7lc7ukjdpk5bjwfemmyp27ovl25ikyd.onion/) | Germany | Operated by [owo.si](https://owo.si/) |
|
||||
| [ay7akchgdh76r4lc62hzd52z6xqoh67loototsetvqxo5o7ngo5q.b32.i2p](http://ay7akchgdh76r4lc62hzd52z6xqoh67loototsetvqxo5o7ngo5q.b32.i2p/) | Germany | Operated by [owo.si](https://owo.si/) |
|
||||
|
||||
## 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 to make Stack Overflow links take you to AnonymousOverflow automatically
|
||||
|
||||
The open-source [Libredirect](https://github.com/libredirect/libredirect) extension for Firefox and Chromium-based desktop browsers has support for redirections to AnonymousOverflow. To enable this, simply open the extension settings, click on Stack Overflow, then toggle "Enable". That's it, now Stack Overflow links will go to AnonymousOverflow.
|
||||
|
||||
The open-source [FREEdirector](https://openuserjs.org/scripts/sjehuda/FREEdirector) user.js script for web browsers with userscript support. You can install it with a web extension like [Greasemonkey](https://greasespot.net/), [Tampermonkey](https://tampermonkey.net/) or [Violentmonkey](https://violentmonkey.github.io/). Once installed, Stack Overflow links will go to AnonymousOverflow.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -49,9 +93,9 @@ You can easily convert StackOverflow URLs to AnonymousOverflow ones by adding th
|
||||
|
||||
```js
|
||||
javascript: (function () {
|
||||
window.location = window.location
|
||||
.toString()
|
||||
.replace(/stackoverflow\.com/, "code.whatever.social");
|
||||
window.location = window.location
|
||||
.toString()
|
||||
.replace(/stackoverflow\.com/, "code.whatever.social");
|
||||
})();
|
||||
```
|
||||
|
||||
@@ -63,18 +107,12 @@ 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/)
|
||||
- [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)
|
||||
- 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.3"
|
||||
var Version = "1.8.3"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,6 +7,8 @@ require (
|
||||
github.com/alecthomas/chroma v0.10.0
|
||||
github.com/gin-gonic/gin v1.8.2
|
||||
github.com/go-resty/resty/v2 v2.7.0
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3
|
||||
github.com/joho/godotenv v1.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -25,9 +27,9 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
|
||||
github.com/ugorji/go/codec v1.2.8 // indirect
|
||||
golang.org/x/crypto v0.4.0 // indirect
|
||||
golang.org/x/net v0.4.0 // indirect
|
||||
golang.org/x/sys v0.3.0 // indirect
|
||||
golang.org/x/text v0.5.0 // indirect
|
||||
golang.org/x/net v0.7.0 // indirect
|
||||
golang.org/x/sys v0.5.0 // indirect
|
||||
golang.org/x/text v0.7.0 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
@@ -27,10 +27,14 @@ 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=
|
||||
@@ -76,20 +80,20 @@ 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/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=
|
||||
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
|
||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
|
||||
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||
golang.org/x/text v0.7.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=
|
||||
|
||||
@@ -32,13 +32,15 @@ func main() {
|
||||
r := gin.Default()
|
||||
|
||||
r.LoadHTMLGlob("templates/*")
|
||||
r.Static("/static", "./public")
|
||||
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(middleware.XssPreventionHeaders())
|
||||
r.Use(middleware.NoCacheMiddleware())
|
||||
r.Use(middleware.OptionsMiddleware())
|
||||
r.Use(middleware.Ratelimit())
|
||||
|
||||
r.GET("/static/*filepath", routes.StaticContent)
|
||||
|
||||
r.GET("/robots.txt", func(c *gin.Context) {
|
||||
c.String(200, "User-agent: *\nDisallow: /")
|
||||
})
|
||||
@@ -46,10 +48,28 @@ func main() {
|
||||
r.GET("/options/:name", routes.ChangeOptions)
|
||||
|
||||
r.GET("/", routes.GetHome)
|
||||
|
||||
r.POST("/", routes.PostHome)
|
||||
|
||||
r.GET("/questions/:id", func(c *gin.Context) {
|
||||
// redirect user to the question with the title
|
||||
c.Redirect(302, fmt.Sprintf("/questions/%s/placeholder", c.Param("id")))
|
||||
})
|
||||
r.GET("/questions/:id/:title", routes.ViewQuestion)
|
||||
r.GET("/questions/:id/:title/:answerId", func(c *gin.Context) {
|
||||
// redirect user to the answer with the title
|
||||
c.Redirect(302, fmt.Sprintf("/questions/%s/%s#%s", c.Param("id"), c.Param("title"), c.Param("answerId")))
|
||||
})
|
||||
r.GET("/exchange/:sub/questions/:id/:title", routes.ViewQuestion)
|
||||
r.GET("/exchange/:sub/questions/:id", func(c *gin.Context) {
|
||||
// redirect user to the question with the title
|
||||
c.Redirect(302, fmt.Sprintf("/exchange/%s/questions/%s/placeholder", c.Param("sub"), c.Param("id")))
|
||||
})
|
||||
r.GET("/exchange/:sub/questions/:id/:title/:answerId", func(c *gin.Context) {
|
||||
// redirect user to the answer with the title
|
||||
c.Redirect(302, fmt.Sprintf("/exchange/%s/questions/%s/%s#%s", c.Param("sub"), c.Param("id"), c.Param("title"), c.Param("answerId")))
|
||||
})
|
||||
|
||||
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;
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 363 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 491 B |
+85
-27
@@ -29,6 +29,20 @@ body {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.card-tags .tag {
|
||||
background-color: var(--meta-bg);
|
||||
color: var(--tag-text-color);
|
||||
padding: 0.5rem;
|
||||
border-radius: 5px;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--code-bg);
|
||||
padding: 0.15rem;
|
||||
@@ -45,26 +59,34 @@ pre {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: var(--muted-text-color);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.post-divider, .answer-divider {
|
||||
.post-divider {
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.answer-divider {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background-color: var(--divider-color);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.answer {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid var(--muted-text-color);
|
||||
}
|
||||
|
||||
.answer:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.answer-meta {
|
||||
border-radius: 5px;
|
||||
padding: 1rem;
|
||||
@@ -72,6 +94,14 @@ img {
|
||||
margin-bottom: 1rem;
|
||||
background-color: var(--meta-bg);
|
||||
color: var(--text-color);
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.answer-meta p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.answer-meta.accepted {
|
||||
@@ -102,26 +132,6 @@ img {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -130,6 +140,54 @@ img {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.s-notice .d-flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.s-notice .mr8 {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.s-notice svg {
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.answers-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.answers-header .sorting form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.answers-header select {
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--meta-bg);
|
||||
color: var(--text-color);
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.answers-header button {
|
||||
width: 2rem;
|
||||
border: 0;
|
||||
height: 2rem;
|
||||
border-radius: 5px;
|
||||
background-color: var(--meta-bg);
|
||||
color: var(--text-color);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 800px) {
|
||||
body {
|
||||
padding-left: 1rem;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"anonymousoverflow/config"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -11,6 +14,17 @@ var ipMap = sync.Map{}
|
||||
|
||||
func Ratelimit() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
if os.Getenv("DISABLE_RATELIMIT") == "true" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/static") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
ip := c.ClientIP()
|
||||
|
||||
// log request count as the value, ip as key
|
||||
@@ -31,14 +45,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()
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -3,6 +3,7 @@ package routes
|
||||
import (
|
||||
"anonymousoverflow/config"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -19,6 +20,8 @@ type urlConversionRequest struct {
|
||||
URL string `form:"url" binding:"required"`
|
||||
}
|
||||
|
||||
var stackExchangeRegex = regexp.MustCompile(`https://(.+).stackexchange.com/questions/`)
|
||||
|
||||
func PostHome(c *gin.Context) {
|
||||
body := urlConversionRequest{}
|
||||
|
||||
@@ -33,15 +36,24 @@ func PostHome(c *gin.Context) {
|
||||
soLink := body.URL
|
||||
|
||||
// validate URL
|
||||
if !strings.HasPrefix(soLink, "https://stackoverflow.com/questions/") {
|
||||
isStackOverflow := strings.HasPrefix(soLink, "https://stackoverflow.com/questions/")
|
||||
isStackExchange := stackExchangeRegex.MatchString(soLink)
|
||||
if !isStackExchange && !isStackOverflow {
|
||||
c.HTML(400, "home.html", gin.H{
|
||||
"errorMessage": "Invalid stack overflow URL",
|
||||
"errorMessage": "Invalid stack overflow/exchange URL",
|
||||
"theme": c.MustGet("theme").(string),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// redirect to the proxied thread
|
||||
c.Redirect(302, fmt.Sprintf("/questions/%s", strings.TrimPrefix(soLink, "https://stackoverflow.com/questions/")))
|
||||
// if stack overflow, trim https://stackoverflow.com
|
||||
if isStackOverflow {
|
||||
c.Redirect(302, strings.TrimPrefix(soLink, "https://stackoverflow.com"))
|
||||
return
|
||||
}
|
||||
|
||||
// if stack exchange, extract the subdomain
|
||||
sub := stackExchangeRegex.FindStringSubmatch(soLink)[1]
|
||||
|
||||
c.Redirect(302, fmt.Sprintf("/exchange/%s/%s", sub, strings.TrimPrefix(soLink, fmt.Sprintf("https://%s.stackexchange.com", sub))))
|
||||
}
|
||||
|
||||
@@ -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, "/")
|
||||
}
|
||||
|
||||
+153
-58
@@ -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"
|
||||
@@ -16,17 +21,66 @@ import (
|
||||
var codeBlockRegex = regexp.MustCompile(`(?s)<pre><code>(.+?)<\/code><\/pre>`)
|
||||
var questionCodeBlockRegex = regexp.MustCompile(`(?s)<pre class=".+"><code( class=".+")?>(.+?)</code></pre>`)
|
||||
|
||||
var soSortValues = map[string]string{
|
||||
"votes": "scoredesc",
|
||||
"trending": "trending",
|
||||
"newest": "modifieddesc",
|
||||
"oldest": "createdasc",
|
||||
}
|
||||
|
||||
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)
|
||||
sortValue := c.Query("sort_by")
|
||||
if sortValue == "" {
|
||||
sortValue = "votes"
|
||||
}
|
||||
|
||||
soSortValue, ok := soSortValues[sortValue]
|
||||
if !ok {
|
||||
soSortValue = soSortValues["votes"]
|
||||
}
|
||||
|
||||
sub := c.Param("sub")
|
||||
|
||||
domain := "stackoverflow.com"
|
||||
|
||||
if sub != "" {
|
||||
domain = fmt.Sprintf("%s.stackexchange.com", sub)
|
||||
}
|
||||
|
||||
soLink := fmt.Sprintf("https://%s/questions/%s/%s?answertab=%s", domain, questionId, questionTitle, soSortValue)
|
||||
|
||||
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
|
||||
}
|
||||
defer resp.RawResponse.Body.Close()
|
||||
|
||||
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,23 +89,56 @@ 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()
|
||||
|
||||
questionTags := utils.GetPostTags(questionPostLayout)
|
||||
newFilteredQuestion.Tags = questionTags
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
questionBodyParentHTML = utils.FindAndReturnComments(questionBodyParentHTML, questionPostLayout)
|
||||
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, domain, questionPostLayout)
|
||||
newFilteredQuestion.Comments = comments
|
||||
|
||||
// parse any code blocks and highlight them
|
||||
answerCodeBlocks := questionCodeBlockRegex.FindAllString(questionBodyParentHTML, -1)
|
||||
@@ -86,77 +173,85 @@ func ViewQuestion(c *gin.Context) {
|
||||
}
|
||||
})
|
||||
|
||||
newFilteredQuestion.Timestamp = questionTimestamp
|
||||
|
||||
userDetails := questionMetadata.Find("div.user-details")
|
||||
|
||||
questionAuthor := ""
|
||||
questionAuthorURL := ""
|
||||
questionAuthorURL := fmt.Sprintf("https://%s", domain)
|
||||
|
||||
userDetails.Find("a").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
|
||||
questionAuthor = s.Text()
|
||||
questionAuthorURL, _ = s.Attr("href")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise it's the second element
|
||||
if i == 1 {
|
||||
questionAuthor = s.Text()
|
||||
questionAuthorURL, _ = s.Attr("href")
|
||||
// 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
|
||||
}
|
||||
})
|
||||
|
||||
answers := []template.HTML{}
|
||||
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 := 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")
|
||||
|
||||
answerAuthorURL := ""
|
||||
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 {
|
||||
return
|
||||
if answerAuthorDetails.Length() > 0 {
|
||||
questionAuthor := answerAuthorDetails.Find("a").First()
|
||||
answerAuthorName = html.EscapeString(questionAuthor.Text())
|
||||
answerAuthorURL += html.EscapeString(questionAuthor.AttrOr("href", ""))
|
||||
}
|
||||
|
||||
if answerAuthorDetails.Length() > 1 {
|
||||
if i == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
answerAuthor := answerAuthorDetails.Find("a").First()
|
||||
|
||||
answerAuthorURL = answerAuthor.AttrOr("href", "")
|
||||
answerAuthorName = answerAuthor.Text()
|
||||
answerTimestamp = s.Find("span.relativetime").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 %s by <a href="https://stackoverflow.com/%s" target="_blank" rel="noopener noreferrer">%s</a></div></div>`, answerTimestamp, answerAuthorURL, answerAuthorName)
|
||||
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)
|
||||
@@ -170,9 +265,12 @@ func ViewQuestion(c *gin.Context) {
|
||||
answerBodyHTML = strings.Replace(answerBodyHTML, codeBlock, highlightedCodeBlock, 1)
|
||||
}
|
||||
|
||||
answerBodyHTML = utils.FindAndReturnComments(answerBodyHTML, postLayout)
|
||||
comments = utils.FindAndReturnComments(answerBodyHTML, domain, postLayout)
|
||||
|
||||
answers = append(answers, template.HTML(answerBodyHTML))
|
||||
newFilteredAnswer.Comments = comments
|
||||
newFilteredAnswer.Body = template.HTML(utils.ReplaceImgTags(answerBodyHTML))
|
||||
|
||||
answers = append(answers, newFilteredAnswer)
|
||||
})
|
||||
|
||||
imagePolicy := "'self' https:"
|
||||
@@ -182,16 +280,13 @@ 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),
|
||||
"sortValue": sortValue,
|
||||
"domain": domain,
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func StaticContent(c *gin.Context) {
|
||||
cleanFilePath := strings.ReplaceAll(c.Param("filepath"), "..", "")
|
||||
|
||||
c.File(fmt.Sprintf("./public/%s", cleanFilePath))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package types
|
||||
|
||||
import "html/template"
|
||||
|
||||
type FilteredAnswer struct {
|
||||
ID string
|
||||
|
||||
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,14 @@
|
||||
package types
|
||||
|
||||
import "html/template"
|
||||
|
||||
type FilteredQuestion struct {
|
||||
Title string
|
||||
Body template.HTML
|
||||
Timestamp string
|
||||
AuthorName string
|
||||
AuthorURL string
|
||||
ShortenedBody string
|
||||
Comments []FilteredComment
|
||||
Tags []string
|
||||
}
|
||||
+21
-13
@@ -1,16 +1,14 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"anonymousoverflow/src/types"
|
||||
"fmt"
|
||||
"strings"
|
||||
"html/template"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
func FindAndReturnComments(inHtml string, postLayout *goquery.Selection) (outHtml string) {
|
||||
outHtml = inHtml
|
||||
|
||||
comments := []string{}
|
||||
func FindAndReturnComments(inHtml, domain string, postLayout *goquery.Selection) (comments []types.FilteredComment) {
|
||||
|
||||
commentsComponent := postLayout.Find("div.js-post-comments-component")
|
||||
|
||||
@@ -20,6 +18,14 @@ func FindAndReturnComments(inHtml string, postLayout *goquery.Selection) (outHtm
|
||||
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")
|
||||
@@ -29,7 +35,7 @@ func FindAndReturnComments(inHtml string, postLayout *goquery.Selection) (outHtm
|
||||
return
|
||||
}
|
||||
|
||||
commentAuthorURL := ""
|
||||
commentAuthorURL := fmt.Sprintf("https://%s", domain)
|
||||
|
||||
commentAuthor := commentBody.Find("span.comment-user")
|
||||
if commentAuthor.Length() == 0 {
|
||||
@@ -38,21 +44,23 @@ func FindAndReturnComments(inHtml string, postLayout *goquery.Selection) (outHtm
|
||||
return
|
||||
}
|
||||
|
||||
commentAuthorURL = commentAuthor.AttrOr("href", "")
|
||||
commentAuthorURL += commentAuthor.AttrOr("href", "")
|
||||
}
|
||||
|
||||
commentTimestamp := commentBody.Find("span.relativetime-clean").Text()
|
||||
|
||||
comment := fmt.Sprintf(`<div class="comment-parent"><div class="comment"><div class="comment-body">%s</div><div class="comment-author">Commented %s by <a href="https://stackoverflow.com%s" target="_blank" rel="noopener noreferrer">%s</a>.</div></div></div>`, commentCopy, commentTimestamp, commentAuthorURL, commentAuthor.Text())
|
||||
newFilteredComment := types.FilteredComment{
|
||||
Text: template.HTML(commentCopy),
|
||||
Timestamp: commentTimestamp,
|
||||
AuthorName: commentAuthor.Text(),
|
||||
AuthorURL: commentAuthorURL,
|
||||
Upvotes: commentScore,
|
||||
}
|
||||
|
||||
comments = append(comments, comment)
|
||||
comments = append(comments, newFilteredComment)
|
||||
|
||||
})
|
||||
|
||||
if len(comments) > 0 {
|
||||
outHtml = inHtml + fmt.Sprintf(`<details class="comments"><summary>Show <b>%d comments</b></summary><div class="comments-parent">%s</div></details>`, len(comments), strings.Join(comments, ""))
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"anonymousoverflow/src/types"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
var imgTagRegex = regexp.MustCompile(`<img[^>]*\s+src\s*=\s*"(.*?)"[^>]*>`)
|
||||
|
||||
func ReplaceImgTags(inHtml string) string {
|
||||
imgTags := imgTagRegex.FindAllString(inHtml, -1)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for _, imgTag := range imgTags {
|
||||
wg.Add(1)
|
||||
go func(imgTag string) {
|
||||
defer wg.Done()
|
||||
srcRegex := regexp.MustCompile(`src\s*=\s*"(.*?)"`)
|
||||
src := srcRegex.FindStringSubmatch(imgTag)[1]
|
||||
|
||||
authToken, _ := generateImageProxyAuth(src)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
inHtml = strings.Replace(inHtml, imgTag, fmt.Sprintf(`<img src="%s/proxy?auth=%s">`, os.Getenv("APP_URL"), authToken), 1)
|
||||
}(imgTag)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
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
|
||||
}
|
||||
+2
-14
@@ -7,13 +7,11 @@ 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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
func GetPostTags(postLayout *goquery.Selection) []string {
|
||||
var tags []string
|
||||
postLayout.Find("a.post-tag").Each(func(i int, s *goquery.Selection) {
|
||||
tags = append(tags, s.Text())
|
||||
})
|
||||
return tags
|
||||
}
|
||||
@@ -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="{{ $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
|
||||
|
||||
+66
-21
@@ -1,19 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html data-theme="{{ .theme }}">
|
||||
<head>
|
||||
<title>{{ .title }}</title>
|
||||
<title>{{ .question.Title }} | AnonymousOverflow</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" />
|
||||
<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">
|
||||
@@ -25,33 +22,81 @@
|
||||
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="{{ .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>
|
||||
<div class="card-tags">
|
||||
{{ range .question.Tags }}
|
||||
<div class="tag">{{ . }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ if .question.Comments }} {{ template "comments.html"
|
||||
.question }} {{end}}
|
||||
</div>
|
||||
<hr class="post-divider" />
|
||||
<h2>Answers</h2>
|
||||
<div class="answers-header">
|
||||
<h2>Answers</h2>
|
||||
<div class="sorting">
|
||||
<form>
|
||||
<select name="sort_by">
|
||||
<option disabled value="">Sort answers by...</option>
|
||||
<option value="votes"{{ if eq .sortValue "votes" }} selected{{ end }}>Votes</option>
|
||||
<option value="trending"{{ if eq .sortValue "trending" }} selected{{ end }}>Trending</option>
|
||||
<option value="newest"{{ if eq .sortValue "newest" }} selected{{ end }}>Date modified (newest first)</option>
|
||||
<option value="oldest"{{ if eq .sortValue "oldest" }} selected{{ end }}>Date created (oldest first)</option>
|
||||
</select>
|
||||
<button type="submit">
|
||||
<img
|
||||
src="/static/icons/sort.svg"
|
||||
alt="Sieve icon"
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{ range $answer := .answers }}
|
||||
<div class="answer">{{ $answer }}</div>
|
||||
<hr class="answer-divider" />
|
||||
<div class="answer" id="{{ $answer.ID }}">
|
||||
<div
|
||||
class="answer-meta{{ if $answer.IsAccepted }} accepted{{end}}"
|
||||
>
|
||||
<p>
|
||||
{{ if $answer.IsAccepted }} Accepted - {{ end }}
|
||||
{{$answer.Upvotes}} Votes
|
||||
</p>
|
||||
<a href="#{{ $answer.ID }}" class="answer-link">
|
||||
<div class="icon">
|
||||
<img src="/static/icons/link.svg" alt="Paperclip icon" />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{{ $answer.Body }}
|
||||
<div class="answer-author-parent">
|
||||
<div class="answer-author">
|
||||
Answered {{ $answer.Timestamp }} by
|
||||
<a
|
||||
href="{{ $answer.AuthorURL }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ $answer.AuthorName }}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{{ if $answer.Comments }} {{ template "comments.html" $answer }}
|
||||
{{end}}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -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