{{ parseContent $board.Actor $opId .Content $thread .Id $page.PostType }}
118 |├── .githooks
└── pre-commit
├── .gitignore
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── activitypub
├── activity.go
├── actor.go
├── object.go
├── pem.go
├── structs.go
├── util.go
└── webfinger.go
├── config-init
├── config-init.docker
├── config
└── config.go
├── databaseschema.psql
├── db
├── database.go
└── report.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── main.go
├── post
├── tripcode.go
└── util.go
├── route
├── routes
│ ├── actor.go
│ ├── admin.go
│ ├── api.go
│ ├── boardmgmt.go
│ ├── main.go
│ ├── news.go
│ └── webfinger.go
├── structs.go
└── util.go
├── util
├── blacklist.go
├── key.go
├── proxy.go
├── util.go
└── verification.go
├── views
├── 403.html
├── 404.html
├── admin.html
├── anews.html
├── archive.html
├── catalog.html
├── clover.png
├── css
│ └── themes
│ │ ├── default.css
│ │ └── gruvbox.css
├── faq.html
├── favicon.png
├── index.html
├── js
│ ├── footerscript.js
│ ├── posts.js
│ ├── themes.js
│ └── timer.js
├── layouts
│ └── main.html
├── locked.png
├── manage.html
├── news.html
├── notfound.png
├── npost.html
├── nposts.html
├── onion.png
├── partials
│ ├── bottom.html
│ ├── footer.html
│ ├── general_scripts.html
│ ├── post_nav.html
│ ├── post_scripts.html
│ ├── posts.html
│ └── top.html
├── pin.png
├── report.html
├── rules.html
├── sensitive.png
└── verify.html
└── webfinger
└── util.go
/.githooks/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # This hook formats every Go file before committing them.
4 | # It helps to enforce a consistent style guide for those who forget to format their code properly.
5 |
6 | STAGED="$(git diff --cached --name-only -- '*.go')"
7 |
8 | if [ -n "$STAGED" ]; then
9 | for file in $STAGED; do
10 | if [ ! -e "$file" ]; then
11 | # file doesn't exist, skip
12 | continue
13 | fi
14 |
15 | # format the file
16 | go fmt "$file"
17 |
18 | # run goimports if it's there
19 | # it organizes imports
20 | if command -v goimports >/dev/null 2>&1; then
21 | goimports -w "$file"
22 | fi
23 |
24 | git add "$file"
25 | done
26 | fi
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .*
2 | !.githooks
3 | *~
4 | #*
5 | public/
6 | config$
7 | config/config-init
8 | clientkey
9 | pem/
10 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.16-alpine AS builder
2 | WORKDIR /build
3 | COPY . .
4 | RUN apk --no-cache add make git
5 |
6 | # Use the 'build' make target when fiber branch is stable
7 | RUN make debug
8 |
9 | FROM alpine:3.14
10 | RUN apk --no-cache add imagemagick exiv2 ttf-opensans
11 | WORKDIR /app
12 | COPY --from=builder /build/fchan /app
13 | COPY static/ /app/static/
14 | COPY views/ /app/views/
15 | COPY databaseschema.psql /app
16 | CMD ["/app/fchan"]
17 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | VERSION=`git describe --tags`
2 | BUILD=`date +%FT%T%z`
3 |
4 | LDFLAGS=-X github.com/FChannel0/FChannel-Server/config.Version=${VERSION} -X github.com/FChannel0/FChannel-Server/config.BuildTime=${BUILD}
5 | FLAGS=-ldflags "-w -s ${LDFLAGS}"
6 | FLAGS_DEBUG=-ldflags "${LDFLAGS}"
7 |
8 | debug:
9 | go build -o fchan ${FLAGS_DEBUG}
10 |
11 | build:
12 | go build -o fchan ${FLAGS}
13 |
14 | clean:
15 | if [ -f "fchan" ]; then rm "fchan"; fi
16 |
17 | .PHONY: clean install
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # About
2 |
3 | FChannel is a [libre](https://en.wikipedia.org/wiki/Free_and_open-source_software), [self-hostable](https://en.wikipedia.org/wiki/Self-hosting_(web_services)), [federated](https://en.wikipedia.org/wiki/Federation_(information_technology)), [imageboard](https://en.wikipedia.org/wiki/Imageboard) platform that utilizes [ActivityPub](https://activitypub.rocks/).
4 |
5 | There are currently several instances federated with each other, for a full list see: https://fchan.xyz
6 |
7 | There is an anon testing FChannel instances on TOR/Loki/I2P. Find more information here: https://fchan.xyz/g/MORL0KUT
8 | It is a testing environment, so the instances might come and go.
9 |
10 | ## To Do List
11 | Current things that will be implemented first are:
12 | - A way to automatically index new instances into a list so others can discover instances as they come online.
13 | - Setting up a server proxy so that clearnet instances can talk to TOR/Loki/I2P instances.
14 | - Other improvements will be made over time, first it needs to be as easy as possible for new instances to come online and connect with others reliably.
15 |
16 | Try and run your own instances and federate with one of the instances above.
17 | Any contributions or suggestions are appreciated. Best way to give immediate feedback is the XMPP: `xmpp:general@rooms.fchannel.org` or Matrix: `#fchan:matrix.org`
18 |
19 | ## Development
20 | To get started on hacking the code of FChannel, it is recommended you setup your
21 | git hooks by simply running `git config core.hooksPath .githooks`.
22 |
23 | This currently helps enforce the Go style guide, but may be expanded upon in the
24 | future.
25 |
26 | Before you make a pull request, ensure everything you changed works as expected,
27 | and to fix errors reported by `go vet` and make your code better with
28 | `staticcheck`.
29 |
30 | ## Server Installation and Configuration
31 |
32 | ### Minimum Server Requirements
33 |
34 | - Go v1.16+
35 | - PostgreSQL
36 | - ImageMagick
37 | - exiv2
38 |
39 | ### Server Installation Instructions
40 |
41 | - Ensure you have Golang installed and set a correct `GOPATH`
42 | - `git clone` the software
43 | - Copy `config-init` to `config/config-init` and change the values appropriately to reflect the instance.
44 | - Create the database, username, and password for psql that is used in the `config` file.
45 | - Build the server with `make`
46 | - Start the server with `./fchan`.
47 |
48 | ### Server Configuration
49 |
50 | #### config file
51 |
52 | `instance:fchan.xyz` Domain name that the host can be located at without www and `http://` or `https://`
53 |
54 | `instancetp:https://` Transfer protocol your domain is using, should be https if possible. Do not put `https://` if you are using `http://`
55 |
56 | `instanceport:3000` Port the server is running on locally, on your server.
57 |
58 | `instancename:FChan` Full name that you want your instances to be called.
59 |
60 | `instancesummary:FChan is a federated image board instance.` Brief description of your instance.
61 |
62 |
63 | `dbhost:localhost` Database host. Most likely leave as `localhost`.
64 |
65 | `dbport:5432` Port number for database. Most likely leave the default value.
66 |
67 | `dbname:fchan_server` Database name for psql.
68 |
69 | `dbuser:admin` Database user that can connect to dbname.
70 |
71 | `dbpass:password` Database password for dbuser.
72 |
73 |
74 | `torproxy:127.0.0.1:9050` Tor proxy route and port, leave blank if you do not want to support
75 |
76 | `instancesalt:put your salt string here` Used for secure tripcodes currently.
77 |
78 | `modkey:3358bed397c1f32cf7532fa37a8778` Set a static modkey instead of one randomly generated on restart.
79 |
80 |
81 | `emailserver:mail.fchan.xyz`
82 |
83 | `emailport:465`
84 |
85 | `emailaddress:contact@fchan.xyz`
86 |
87 | `emailpass:password`
88 |
89 | `emailnotify:email1@so.co, email2@bo.uo` Comma seperated emails To.
90 |
91 | ### Local testing
92 |
93 | When testing on a local env when setting the `instance` value in the config file you have to append the port number to the local address eg. `instance:localhost:3000` with `instanceport` also being set to the same port.
94 |
95 | If you want to test federation between servers locally you have to use your local ip as the `instance` eg. `instance:192.168.0.2:3000` and `instance:192.168.0.2:4000` adding the port to localhost will not route correctly.
96 |
97 | ### Managing the server
98 |
99 | To access the managment page to create new boards or subscribe to other boards, when you start the server the console will output the `Mod key` and `Admin Login`
100 | Use the `Mod key` by appending it to your server's url, `https://fchan.xyz/[Mod key]` once there you will be prompted for the `Admin Login` credentials.
101 | You can manage each board by appending the `Mod key` to the desired board url: `https://fchan.xyz/[Mod Key]/g`
102 | The `Mod key` is not static and is reset on server restart.
103 |
104 | ## Server Update
105 |
106 | Check the git repo for the latest commits. If there are commits you want to update to, git pull and restart the instance.
107 |
108 | ## Networking
109 |
110 | ### NGINX Template
111 |
112 | Use [Certbot](https://github.com/certbot/certbot), (or your tool of choice) to setup SSL.
113 |
114 | ```
115 | server {
116 | listen 80;
117 | listen [::]:80;
118 |
119 | root /var/www/html;
120 |
121 | server_name DOMAIN_NAME;
122 |
123 | client_max_body_size 100M;
124 |
125 | location / {
126 | # First attempt to serve request as file, then
127 | # as directory, then fall back to displaying a 404.
128 | #try_files $uri $uri/ =404;
129 | proxy_pass http://localhost:3000;
130 | proxy_http_version 1.1;
131 | proxy_set_header Upgrade $http_upgrade;
132 | proxy_set_header Connection 'upgrade';
133 | proxy_set_header Host $host;
134 | proxy_set_header X-Real-IP $remote_addr;
135 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
136 | proxy_set_header X-Forwarded-Proto $scheme;
137 | proxy_cache_bypass $http_upgrade;
138 | }
139 | }
140 | ```
141 |
142 | #### Using Certbot With NGINX
143 |
144 | - After installing Certbot and the Nginx plugin, generate the certificate: `sudo certbot --nginx --agree-tos --redirect --rsa-key-size 4096 --hsts --staple-ocsp --email YOUR_EMAIL -d DOMAIN_NAME`
145 | - Add a job to cron so the certificate will be renewed automatically: `echo "0 0 * * * root certbot renew --quiet --no-self-upgrade --post-hook 'systemctl reload nginx'" | sudo tee -a /etc/cron.d/renew_certbot`
146 |
147 | ### Apache
148 |
149 | `Please consider submitting a pull request if you set up a FChannel instance with Apache with instructions on how to do so`
150 |
151 | ### Caddy
152 |
153 | `Please consider submitting a pull request if you set up a FChannel instance with Caddy with instructions on how to do so`
154 |
155 | ### Docker
156 |
157 | A Dockerfile is provided, and an example `docker-compose.yml` exists to base your Docker setup on.
158 | You should use the `config-init.docker` file to configure it and it will work more or less out of the box with it, you should just need some minor configuration changes to test it out.
159 |
160 | Remember, you may need to look at [the section on local testing](#local-testing)
161 | to use 100% of FChannel's features.
162 |
--------------------------------------------------------------------------------
/activitypub/pem.go:
--------------------------------------------------------------------------------
1 | package activitypub
2 |
3 | import (
4 | crand "crypto/rand"
5 | "crypto/rsa"
6 | "crypto/x509"
7 | "encoding/pem"
8 | "errors"
9 | "io/ioutil"
10 | "os"
11 | "regexp"
12 | "strings"
13 |
14 | "github.com/FChannel0/FChannel-Server/config"
15 | "github.com/FChannel0/FChannel-Server/util"
16 | )
17 |
18 | type Signature struct {
19 | KeyId string
20 | Headers []string
21 | Signature string
22 | Algorithm string
23 | }
24 |
25 | func CreatePem(actor Actor) error {
26 | privatekey, err := rsa.GenerateKey(crand.Reader, 2048)
27 | if err != nil {
28 | return util.MakeError(err, "CreatePem")
29 | }
30 |
31 | privateKeyBytes := x509.MarshalPKCS1PrivateKey(privatekey)
32 |
33 | privateKeyBlock := &pem.Block{
34 | Type: "RSA PRIVATE KEY",
35 | Bytes: privateKeyBytes,
36 | }
37 |
38 | privatePem, err := os.Create("./pem/board/" + actor.Name + "-private.pem")
39 | if err != nil {
40 | return util.MakeError(err, "CreatePem")
41 | }
42 |
43 | if err := pem.Encode(privatePem, privateKeyBlock); err != nil {
44 | return util.MakeError(err, "CreatePem")
45 | }
46 |
47 | publickey := &privatekey.PublicKey
48 | publicKeyBytes, err := x509.MarshalPKIXPublicKey(publickey)
49 | if err != nil {
50 | return util.MakeError(err, "CreatePem")
51 | }
52 |
53 | publicKeyBlock := &pem.Block{
54 | Type: "PUBLIC KEY",
55 | Bytes: publicKeyBytes,
56 | }
57 |
58 | publicPem, err := os.Create("./pem/board/" + actor.Name + "-public.pem")
59 | if err != nil {
60 | return util.MakeError(err, "CreatePem")
61 | }
62 |
63 | if err := pem.Encode(publicPem, publicKeyBlock); err != nil {
64 | return util.MakeError(err, "CreatePem")
65 | }
66 |
67 | _, err = os.Stat("./pem/board/" + actor.Name + "-public.pem")
68 | if os.IsNotExist(err) {
69 | return util.MakeError(err, "CreatePem")
70 | } else {
71 | return StorePemToDB(actor)
72 | }
73 |
74 | config.Log.Println(`Created PEM keypair for the "` + actor.Name + `" board. Please keep in mind that
75 | the PEM key is crucial in identifying yourself as the legitimate owner of the board,
76 | so DO NOT LOSE IT!!! If you lose it, YOU WILL LOSE ACCESS TO YOUR BOARD!`)
77 |
78 | return nil
79 | }
80 |
81 | func CreatePublicKeyFromPrivate(actor *Actor, publicKeyPem string) error {
82 | publicFilename, err := GetActorPemFileFromDB(publicKeyPem)
83 | if err != nil {
84 | return util.MakeError(err, "CreatePublicKeyFromPrivate")
85 | }
86 |
87 | privateFilename := strings.ReplaceAll(publicFilename, "public.pem", "private.pem")
88 | if _, err := os.Stat(privateFilename); err == nil {
89 | // Not a lost cause
90 | priv, err := ioutil.ReadFile(privateFilename)
91 | if err != nil {
92 | return util.MakeError(err, "CreatePublicKeyFromPrivate")
93 | }
94 |
95 | block, _ := pem.Decode([]byte(priv))
96 | if block == nil || block.Type != "RSA PRIVATE KEY" {
97 | return errors.New("failed to decode PEM block containing public key")
98 | }
99 |
100 | key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
101 | if err != nil {
102 | return util.MakeError(err, "CreatePublicKeyFromPrivate")
103 | }
104 |
105 | publicKeyDer, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
106 | if err != nil {
107 | return util.MakeError(err, "CreatePublicKeyFromPrivate")
108 | }
109 |
110 | pubKeyBlock := pem.Block{
111 | Type: "PUBLIC KEY",
112 | Headers: nil,
113 | Bytes: publicKeyDer,
114 | }
115 |
116 | publicFileWriter, err := os.Create(publicFilename)
117 | if err != nil {
118 | return util.MakeError(err, "CreatePublicKeyFromPrivate")
119 | }
120 |
121 | if err := pem.Encode(publicFileWriter, &pubKeyBlock); err != nil {
122 | return util.MakeError(err, "CreatePublicKeyFromPrivate")
123 | }
124 | } else {
125 | config.Log.Println(`\nUnable to locate private key from public key generation. Now,
126 | this means that you are now missing the proof that you are the
127 | owner of the "` + actor.Name + `" board. If you are the developer,
128 | then your job is just as easy as generating a new keypair, but
129 | if this board is live, then you'll also have to convince the other
130 | owners to switch their public keys for you so that they will start
131 | accepting your posts from your board from this site. Good luck ;)`)
132 | return errors.New("unable to locate private key")
133 | }
134 | return nil
135 | }
136 |
137 | func GetActorPemFromDB(pemID string) (PublicKeyPem, error) {
138 | var pem PublicKeyPem
139 |
140 | query := `select id, owner, file from publickeypem where id=$1`
141 |
142 | if err := config.DB.QueryRow(query, pemID).Scan(&pem.Id, &pem.Owner, &pem.PublicKeyPem); err != nil {
143 | return pem, util.MakeError(err, "GetActorPemFromDB")
144 | }
145 |
146 | dir, _ := os.Getwd()
147 | dir = dir + "" + strings.Replace(pem.PublicKeyPem, ".", "", 1)
148 | f, err := os.ReadFile(dir)
149 | if err != nil {
150 | return pem, util.MakeError(err, "GetActorPemFromDB")
151 | }
152 |
153 | pem.PublicKeyPem = strings.ReplaceAll(string(f), "\r\n", `\n`)
154 |
155 | return pem, nil
156 | }
157 |
158 | func GetActorPemFileFromDB(pemID string) (string, error) {
159 | query := `select file from publickeypem where id=$1`
160 | rows, err := config.DB.Query(query, pemID)
161 | if err != nil {
162 | return "", util.MakeError(err, "GetActorPemFileFromDB")
163 | }
164 |
165 | defer rows.Close()
166 |
167 | var file string
168 | rows.Next()
169 | rows.Scan(&file)
170 |
171 | return file, nil
172 | }
173 |
174 | func StorePemToDB(actor Actor) error {
175 | query := "select publicKeyPem from actor where id=$1"
176 | rows, err := config.DB.Query(query, actor.Id)
177 | if err != nil {
178 | return util.MakeError(err, "StorePemToDB")
179 | }
180 |
181 | defer rows.Close()
182 |
183 | var result string
184 | rows.Next()
185 | rows.Scan(&result)
186 |
187 | if result != "" {
188 | return errors.New("already storing public key for actor")
189 | }
190 |
191 | publicKeyPem := actor.Id + "#main-key"
192 | query = "update actor set publicKeyPem=$1 where id=$2"
193 | if _, err := config.DB.Exec(query, publicKeyPem, actor.Id); err != nil {
194 | return util.MakeError(err, "StorePemToDB")
195 | }
196 |
197 | file := "./pem/board/" + actor.Name + "-public.pem"
198 | query = "insert into publicKeyPem (id, owner, file) values($1, $2, $3)"
199 | _, err = config.DB.Exec(query, publicKeyPem, actor.Id, file)
200 | return util.MakeError(err, "StorePemToDB")
201 | }
202 |
203 | func ParseHeaderSignature(signature string) Signature {
204 | var nsig Signature
205 |
206 | keyId := regexp.MustCompile(`keyId=`)
207 | headers := regexp.MustCompile(`headers=`)
208 | sig := regexp.MustCompile(`signature=`)
209 | algo := regexp.MustCompile(`algorithm=`)
210 |
211 | signature = strings.ReplaceAll(signature, "\"", "")
212 | parts := strings.Split(signature, ",")
213 |
214 | for _, e := range parts {
215 | if keyId.MatchString(e) {
216 | nsig.KeyId = keyId.ReplaceAllString(e, "")
217 | continue
218 | }
219 |
220 | if headers.MatchString(e) {
221 | header := headers.ReplaceAllString(e, "")
222 | nsig.Headers = strings.Split(header, " ")
223 | continue
224 | }
225 |
226 | if sig.MatchString(e) {
227 | nsig.Signature = sig.ReplaceAllString(e, "")
228 | continue
229 | }
230 |
231 | if algo.MatchString(e) {
232 | nsig.Algorithm = algo.ReplaceAllString(e, "")
233 | continue
234 | }
235 | }
236 |
237 | return nsig
238 | }
239 |
--------------------------------------------------------------------------------
/activitypub/structs.go:
--------------------------------------------------------------------------------
1 | package activitypub
2 |
3 | import (
4 | "time"
5 |
6 | "encoding/json"
7 | "html/template"
8 | )
9 |
10 | type AtContextRaw struct {
11 | Context json.RawMessage `json:"@context,omitempty"`
12 | }
13 |
14 | type ActivityRaw struct {
15 | AtContextRaw
16 | Type string `json:"type,omitempty"`
17 | Id string `json:"id,omitempty"`
18 | Name string `json:"name,omitempty"`
19 | Summary string `json:"summary,omitempty"`
20 | Auth string `json:"auth,omitempty"`
21 | ToRaw json.RawMessage `json:"to,omitempty"`
22 | BtoRaw json.RawMessage `json:"bto,omitempty"`
23 | CcRaw json.RawMessage `json:"cc,omitempty"`
24 | Published time.Time `json:"published,omitempty"`
25 | ActorRaw json.RawMessage `json:"actor,omitempty"`
26 | ObjectRaw json.RawMessage `json:"object,omitempty"`
27 | }
28 |
29 | type AtContext struct {
30 | Context string `json:"@context,omitempty"`
31 | }
32 |
33 | type AtContextArray struct {
34 | Context []interface{} `json:"@context,omitempty"`
35 | }
36 |
37 | type AtContextString struct {
38 | Context string `json:"@context,omitempty"`
39 | }
40 |
41 | type ActorString struct {
42 | Actor string `json:"actor,omitempty"`
43 | }
44 |
45 | type ObjectArray struct {
46 | Object []ObjectBase `json:"object,omitempty"`
47 | }
48 |
49 | type Object struct {
50 | Object *ObjectBase `json:"object,omitempty"`
51 | }
52 |
53 | type ObjectString struct {
54 | Object string `json:"object,omitempty"`
55 | }
56 |
57 | type ToArray struct {
58 | To []string `json:"to,omitempty"`
59 | }
60 |
61 | type ToString struct {
62 | To string `json:"to,omitempty"`
63 | }
64 |
65 | type CcArray struct {
66 | Cc []string `json:"cc,omitempty"`
67 | }
68 |
69 | type CcOjectString struct {
70 | Cc string `json:"cc,omitempty"`
71 | }
72 |
73 | type Actor struct {
74 | Type string `json:"type,omitempty"`
75 | Id string `json:"id,omitempty"`
76 | Inbox string `json:"inbox,omitempty"`
77 | Outbox string `json:"outbox,omitempty"`
78 | Following string `json:"following,omitempty"`
79 | Followers string `json:"followers,omitempty"`
80 | Name string `json:"name,omitempty"`
81 | PreferredUsername string `json:"preferredUsername,omitempty"`
82 | PublicKey PublicKeyPem `json:"publicKey,omitempty"`
83 | Summary string `json:"summary,omitempty"`
84 | AuthRequirement []string `json:"authrequirement,omitempty"`
85 | Restricted bool `json:"restricted"`
86 | }
87 |
88 | type PublicKeyPem struct {
89 | Id string `json:"id,omitempty"`
90 | Owner string `json:"owner,omitempty"`
91 | PublicKeyPem string `json:"publicKeyPem,omitempty"`
92 | }
93 |
94 | type Activity struct {
95 | AtContext
96 | Type string `json:"type,omitempty"`
97 | Id string `json:"id,omitempty"`
98 | Actor *Actor `json:"actor,omitempty"`
99 | Name string `json:"name,omitempty"`
100 | Summary string `json:"summary,omitempty"`
101 | Auth string `json:"auth,omitempty"`
102 | To []string `json:"to,omitempty"`
103 | Bto []string `json:"bto,omitempty"`
104 | Cc []string `json:"cc,omitempty"`
105 | Published time.Time `json:"published,omitempty"`
106 | Object ObjectBase `json:"object,omitempty"`
107 | }
108 |
109 | type ObjectBase struct {
110 | Type string `json:"type,omitempty"`
111 | Id string `json:"id,omitempty"`
112 | Name string `json:"name,omitempty"`
113 | Option []string `json:"option,omitempty"`
114 | Alias string `json:"alias,omitempty"`
115 | AttributedTo string `json:"attributedTo,omitempty"`
116 | TripCode string `json:"tripcode,omitempty"`
117 | Actor string `json:"actor,omitempty"`
118 | Audience string `json:"audience,omitempty"`
119 | ContentHTML template.HTML `json:"contenthtml,omitempty"`
120 | Content string `json:"content,omitempty"`
121 | EndTime string `json:"endTime,omitempty"`
122 | Generator string `json:"generator,omitempty"`
123 | Icon string `json:"icon,omitempty"`
124 | Image string `json:"image,omitempty"`
125 | InReplyTo []ObjectBase `json:"inReplyTo,omitempty"`
126 | Location string `json:"location,omitempty"`
127 | Preview *NestedObjectBase `json:"preview,omitempty"`
128 | Published time.Time `json:"published,omitempty"`
129 | Updated time.Time `json:"updated,omitempty"`
130 | Object *NestedObjectBase `json:"object,omitempty"`
131 | Attachment []ObjectBase `json:"attachment,omitempty"`
132 | Replies CollectionBase `json:"replies,omitempty"`
133 | StartTime string `json:"startTime,omitempty"`
134 | Summary string `json:"summary,omitempty"`
135 | Tag []ObjectBase `json:"tag,omitempty"`
136 | Wallet []CryptoCur `json:"wallet,omitempty"`
137 | Deleted string `json:"deleted,omitempty"`
138 | Url []ObjectBase `json:"url,omitempty"`
139 | Href string `json:"href,omitempty"`
140 | To []string `json:"to,omitempty"`
141 | Bto []string `json:"bto,omitempty"`
142 | Cc []string `json:"cc,omitempty"`
143 | Bcc string `json:"Bcc,omitempty"`
144 | MediaType string `json:"mediatype,omitempty"`
145 | Duration string `json:"duration,omitempty"`
146 | Size int64 `json:"size,omitempty"`
147 | Sensitive bool `json:"sensitive,omitempty"`
148 | Sticky bool `json:"sticky,omitempty"`
149 | Locked bool `json:"locked,omitempty"`
150 | }
151 |
152 | type CryptoCur struct {
153 | Type string `json:"type,omitempty"`
154 | Address string `json:"address,omitempty"`
155 | }
156 |
157 | type NestedObjectBase struct {
158 | AtContext
159 | Type string `json:"type,omitempty"`
160 | Id string `json:"id,omitempty"`
161 | Name string `json:"name,omitempty"`
162 | Alias string `json:"alias,omitempty"`
163 | AttributedTo string `json:"attributedTo,omitempty"`
164 | TripCode string `json:"tripcode,omitempty"`
165 | Actor string `json:"actor,omitempty"`
166 | Audience string `json:"audience,omitempty"`
167 | ContentHTML template.HTML `json:"contenthtml,omitempty"`
168 | Content string `json:"content,omitempty"`
169 | EndTime string `json:"endTime,omitempty"`
170 | Generator string `json:"generator,omitempty"`
171 | Icon string `json:"icon,omitempty"`
172 | Image string `json:"image,omitempty"`
173 | InReplyTo []ObjectBase `json:"inReplyTo,omitempty"`
174 | Location string `json:"location,omitempty"`
175 | Preview ObjectBase `json:"preview,omitempty"`
176 | Published time.Time `json:"published,omitempty"`
177 | Attachment []ObjectBase `json:"attachment,omitempty"`
178 | Replies *CollectionBase `json:"replies,omitempty"`
179 | StartTime string `json:"startTime,omitempty"`
180 | Summary string `json:"summary,omitempty"`
181 | Tag []ObjectBase `json:"tag,omitempty"`
182 | Updated time.Time `json:"updated,omitempty"`
183 | Deleted string `json:"deleted,omitempty"`
184 | Url []ObjectBase `json:"url,omitempty"`
185 | Href string `json:"href,omitempty"`
186 | To []string `json:"to,omitempty"`
187 | Bto []string `json:"bto,omitempty"`
188 | Cc []string `json:"cc,omitempty"`
189 | Bcc string `json:"Bcc,omitempty"`
190 | MediaType string `json:"mediatype,omitempty"`
191 | Duration string `json:"duration,omitempty"`
192 | Size int64 `json:"size,omitempty"`
193 | }
194 |
195 | type CollectionBase struct {
196 | Actor Actor `json:"actor,omitempty"`
197 | Summary string `json:"summary,omitempty"`
198 | Type string `json:"type,omitempty"`
199 | TotalItems int `json:"totalItems,omitempty"`
200 | TotalImgs int `json:"totalImgs,omitempty"`
201 | OrderedItems []ObjectBase `json:"orderedItems,omitempty"`
202 | Items []ObjectBase `json:"items,omitempty"`
203 | }
204 |
205 | type Collection struct {
206 | AtContext
207 | CollectionBase
208 | }
209 |
210 | type ObjectBaseSortDesc []ObjectBase
211 |
212 | func (a ObjectBaseSortDesc) Len() int { return len(a) }
213 | func (a ObjectBaseSortDesc) Less(i, j int) bool { return a[i].Updated.After(a[j].Updated) }
214 | func (a ObjectBaseSortDesc) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
215 |
216 | type ObjectBaseSortAsc []ObjectBase
217 |
218 | func (a ObjectBaseSortAsc) Len() int { return len(a) }
219 | func (a ObjectBaseSortAsc) Less(i, j int) bool { return a[i].Published.Before(a[j].Published) }
220 | func (a ObjectBaseSortAsc) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
221 |
--------------------------------------------------------------------------------
/activitypub/webfinger.go:
--------------------------------------------------------------------------------
1 | package activitypub
2 |
3 | import (
4 | "encoding/json"
5 | "io/ioutil"
6 | "net/http"
7 | "strings"
8 | "time"
9 |
10 | "github.com/FChannel0/FChannel-Server/config"
11 | "github.com/FChannel0/FChannel-Server/util"
12 | )
13 |
14 | type Webfinger struct {
15 | Subject string `json:"subject,omitempty"`
16 | Links []WebfingerLink `json:"links,omitempty"`
17 | }
18 |
19 | type WebfingerLink struct {
20 | Rel string `json:"rel,omitempty"`
21 | Type string `json:"type,omitempty"`
22 | Href string `json:"href,omitempty"`
23 | }
24 |
25 | func GetActor(id string) (Actor, error) {
26 | var respActor Actor
27 |
28 | if id == "" {
29 | return respActor, nil
30 | }
31 |
32 | actor, instance := GetActorAndInstance(id)
33 |
34 | if ActorCache[actor+"@"+instance].Id != "" {
35 | respActor = ActorCache[actor+"@"+instance]
36 | return respActor, nil
37 | }
38 |
39 | req, err := http.NewRequest("GET", strings.TrimSpace(id), nil)
40 | if err != nil {
41 | return respActor, util.MakeError(err, "GetActor")
42 | }
43 |
44 | req.Header.Set("Accept", config.ActivityStreams)
45 |
46 | resp, err := util.RouteProxy(req)
47 |
48 | if err != nil {
49 | return respActor, util.MakeError(err, "GetActor")
50 | }
51 |
52 | defer resp.Body.Close()
53 | body, _ := ioutil.ReadAll(resp.Body)
54 |
55 | if err := json.Unmarshal(body, &respActor); err != nil {
56 | return respActor, util.MakeError(err, "GetActor")
57 | }
58 |
59 | ActorCache[actor+"@"+instance] = respActor
60 |
61 | return respActor, nil
62 | }
63 |
64 | //looks for actor with pattern of board@instance
65 | func FingerActor(path string) (Actor, error) {
66 | var nActor Actor
67 |
68 | actor, instance := GetActorAndInstance(path)
69 |
70 | if actor == "" && instance == "" {
71 | return nActor, nil
72 | }
73 |
74 | if ActorCache[actor+"@"+instance].Id != "" {
75 | nActor = ActorCache[actor+"@"+instance]
76 | } else {
77 | resp, err := FingerRequest(actor, instance)
78 | if err != nil {
79 | return nActor, util.MakeError(err, "FingerActor finger request")
80 | }
81 |
82 | if resp != nil && resp.StatusCode == 200 {
83 | defer resp.Body.Close()
84 |
85 | body, err := ioutil.ReadAll(resp.Body)
86 | if err != nil {
87 | return nActor, util.MakeError(err, "FingerActor read resp")
88 | }
89 |
90 | if err := json.Unmarshal(body, &nActor); err != nil {
91 | return nActor, util.MakeError(err, "FingerActor unmarshal")
92 | }
93 |
94 | ActorCache[actor+"@"+instance] = nActor
95 | }
96 | }
97 |
98 | return nActor, nil
99 | }
100 |
101 | func FingerRequest(actor string, instance string) (*http.Response, error) {
102 | acct := "acct:" + actor + "@" + instance
103 |
104 | // TODO: respect https
105 | req, err := http.NewRequest("GET", "http://"+instance+"/.well-known/webfinger?resource="+acct, nil)
106 |
107 | if err != nil {
108 | return nil, util.MakeError(err, "FingerRequest")
109 | }
110 |
111 | resp, err := util.RouteProxy(req)
112 | if err != nil {
113 | return resp, err
114 | }
115 |
116 | var finger Webfinger
117 |
118 | if resp.StatusCode == 200 {
119 | body, err := ioutil.ReadAll(resp.Body)
120 | if err != nil {
121 | return resp, err
122 | }
123 |
124 | if err := json.Unmarshal(body, &finger); err != nil {
125 | return resp, util.MakeError(err, "FingerRequest")
126 | }
127 | }
128 |
129 | if len(finger.Links) > 0 {
130 | for _, e := range finger.Links {
131 | if e.Type == "application/activity+json" {
132 | req, err = http.NewRequest("GET", e.Href, nil)
133 |
134 | if err != nil {
135 | return resp, util.MakeError(err, "FingerRequest")
136 | }
137 |
138 | break
139 | }
140 | }
141 | }
142 |
143 | req.Header.Set("Accept", config.ActivityStreams)
144 | if resp, err = util.RouteProxy(req); err != nil {
145 | return resp, util.MakeError(err, "FingerRequest")
146 | }
147 |
148 | return resp, nil
149 | }
150 |
151 | func AddInstanceToIndexDB(actor string) error {
152 | // TODO: completely disabling this until it is actually reasonable to turn it on
153 | // only actually allow this when it more or less works, i.e. can post, make threads, manage boards, etc
154 | return nil
155 |
156 | //sleep to be sure the webserver is fully initialized
157 | //before making finger request
158 | time.Sleep(15 * time.Second)
159 |
160 | nActor, err := FingerActor(actor)
161 | if err != nil {
162 | return util.MakeError(err, "IsValidActor")
163 | }
164 |
165 | if nActor.Id == "" {
166 | return nil
167 | }
168 |
169 | // TODO: maybe allow different indexes?
170 | reqActivity := Activity{Id: "https://fchan.xyz/followers"}
171 | followers, err := reqActivity.GetCollection()
172 | if err != nil {
173 | return util.MakeError(err, "IsValidActor")
174 | }
175 |
176 | var alreadyIndex = false
177 | for _, e := range followers.Items {
178 | if e.Id == nActor.Id {
179 | alreadyIndex = true
180 | }
181 | }
182 |
183 | if !alreadyIndex {
184 | actor := Actor{Id: "https://fchan.xyz"}
185 | return actor.AddFollower(nActor.Id)
186 | }
187 |
188 | return nil
189 | }
190 |
--------------------------------------------------------------------------------
/config-init:
--------------------------------------------------------------------------------
1 | instance:fchan.xyz
2 | instanceport:3000
3 | instancename:FChan
4 | instancesummary:FChan is a federated image board instance.
5 |
6 | ## For `instancetp` if you plan to support https
7 | ## make sure you setup the ssl certs before running the server initially
8 | ## do not start with http:// and then switch to https://
9 | ## this will cause issues if switched from on protocol to the other.
10 | ## If you do, the database entries will have to be converted to support the change
11 | ## this will cause a lot of headaches if switched back and forth.
12 | ## Choose which one you are going to support and do not change it for best results.
13 |
14 | instancetp:https://
15 |
16 | dbhost:localhost
17 | dbport:5432
18 | dbname:server
19 | dbuser:postgres
20 | dbpass:password
21 |
22 | emailserver:
23 | emailport:
24 | emailaddress:
25 | emailpass:
26 |
27 | ## comma seperated emails To
28 | emailnotify:
29 |
30 | ## enter proxy ip and port if you want to have tor connections supported
31 | ## 127.0.0.1:9050 default
32 | torproxy:
33 |
34 | ## add your instance salt here for secure tripcodes
35 | instancesalt:
36 |
37 | ## this is the key used to access moderation pages leave empty to randomly generate each restart
38 | ## share with other admin or jannies if you are having others to moderate
39 | modkey:
40 |
--------------------------------------------------------------------------------
/config-init.docker:
--------------------------------------------------------------------------------
1 | instance:fchan.xyz
2 | instanceport:3000
3 | instancename:FChan
4 | instancesummary:FChan is a federated image board instance.
5 |
6 | ## For `instancetp` if you plan to support https
7 | ## make sure you setup the ssl certs before running the server initially
8 | ## do not start with http:// and then switch to https://
9 | ## this will cause issues if switched from on protocol to the other.
10 | ## If you do, the database entries will have to be converted to support the change
11 | ## this will cause a lot of headaches if switched back and forth.
12 | ## Choose which one you are going to support and do not change it for best results.
13 |
14 | instancetp:http://
15 |
16 | ## postgres is at postgres and is setup with default credentials
17 | dbhost:postgres
18 | dbport:5432
19 | dbname:fchan
20 | dbuser:fchan
21 | dbpass:hackme
22 |
23 | emailserver:
24 | emailport:
25 | emailaddress:
26 | emailpass:
27 |
28 | ## enter proxy ip and port if you want to have tor connections supported
29 | ## 127.0.0.1:9050 default
30 |
31 | torproxy:
32 |
33 | ## Change to true if you want your instance to be added to the public instance index
34 |
35 | publicindex:false
36 |
37 | ## add your instance salt here for secure tripcodes
38 |
39 | instancesalt:
40 |
--------------------------------------------------------------------------------
/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "bufio"
5 | "database/sql"
6 | "log"
7 | "os"
8 | "strconv"
9 | "strings"
10 | )
11 |
12 | var Port = ":" + GetConfigValue("instanceport", "3000")
13 | var TP = GetConfigValue("instancetp", "")
14 | var Domain = TP + "" + GetConfigValue("instance", "")
15 | var InstanceName = GetConfigValue("instancename", "")
16 | var InstanceSummary = GetConfigValue("instancesummary", "")
17 | var SiteEmail = GetConfigValue("emailaddress", "") //contact@fchan.xyz
18 | var SiteEmailPassword = GetConfigValue("emailpass", "")
19 | var SiteEmailServer = GetConfigValue("emailserver", "") //mail.fchan.xyz
20 | var SiteEmailPort = GetConfigValue("emailport", "") //587
21 | var SiteEmailNotifyTo = GetConfigValue("emailnotify", "")
22 | var TorProxy = GetConfigValue("torproxy", "") //127.0.0.1:9050
23 | var Salt = GetConfigValue("instancesalt", "")
24 | var DBHost = GetConfigValue("dbhost", "localhost")
25 | var DBPort, _ = strconv.Atoi(GetConfigValue("dbport", "5432"))
26 | var DBUser = GetConfigValue("dbuser", "postgres")
27 | var DBPassword = GetConfigValue("dbpass", "password")
28 | var DBName = GetConfigValue("dbname", "server")
29 | var CookieKey = GetConfigValue("cookiekey", "")
30 | var ActivityStreams = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
31 | var AuthReq = []string{"captcha", "email", "passphrase"}
32 | var PostCountPerPage = 10
33 | var SupportedFiles = []string{"image/gif", "image/jpeg", "image/png", "image/webp", "image/apng", "video/mp4", "video/ogg", "video/webm", "audio/mpeg", "audio/ogg", "audio/wav", "audio/wave", "audio/x-wav"}
34 | var Log = log.New(os.Stdout, "", log.Ltime)
35 | var MediaHashs = make(map[string]string)
36 | var Key = GetConfigValue("modkey", "")
37 | var Themes []string
38 | var DB *sql.DB
39 |
40 | // TODO Change this to some other config format like YAML
41 | // to save into a struct and only read once
42 | func GetConfigValue(value string, ifnone string) string {
43 | file, err := os.Open("config/config-init")
44 |
45 | if err != nil {
46 | Log.Println(err)
47 | return ifnone
48 | }
49 |
50 | defer file.Close()
51 |
52 | lines := bufio.NewScanner(file)
53 |
54 | for lines.Scan() {
55 | line := strings.SplitN(lines.Text(), ":", 2)
56 | if line[0] == value {
57 | return line[1]
58 | }
59 | }
60 |
61 | return ifnone
62 | }
63 |
--------------------------------------------------------------------------------
/databaseschema.psql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS actor(
2 | type varchar(50) default '',
3 | id varchar(100) UNIQUE PRIMARY KEY,
4 | name varchar(50) default '',
5 | preferedusername varchar(100) default '',
6 | summary varchar(200) default '',
7 | inbox varchar(100) default '',
8 | outbox varchar(100) default '',
9 | following varchar(100) default '',
10 | followers varchar(100) default '',
11 | restricted boolean default false
12 | );
13 |
14 | CREATE TABLE IF NOT EXISTS replies(
15 | id varchar(100),
16 | inreplyto varchar(100)
17 | );
18 |
19 | CREATE TABLE IF NOT EXISTS following(
20 | id varchar(100),
21 | following varchar(100)
22 | );
23 |
24 | CREATE TABLE IF NOT EXISTS follower(
25 | id varchar(100),
26 | follower varchar(100)
27 | );
28 |
29 | CREATE TABLE IF NOT EXISTS verification(
30 | type varchar(50) default '',
31 | identifier varchar(50),
32 | code varchar(50),
33 | created TIMESTAMP default NOW()
34 | );
35 |
36 | CREATE TABLE IF NOT EXISTS reported(
37 | id varchar(100),
38 | count int,
39 | board varchar(100),
40 | reason varchar(100)
41 | );
42 |
43 | CREATE TABLE IF NOT EXISTS verificationcooldown(
44 | code varchar(50),
45 | created TIMESTAMP default NOW()
46 | );
47 |
48 | CREATE TABLE IF NOT EXISTS boardaccess(
49 | identifier varchar(100),
50 | code varchar(50),
51 | board varchar(100),
52 | type varchar(50)
53 | );
54 |
55 | CREATE TABLE IF NOT EXISTS crossverification(
56 | verificationcode varchar(50),
57 | code varchar(50)
58 | );
59 |
60 | CREATE TABLE IF NOT EXISTS actorauth(
61 | type varchar(50),
62 | board varchar(100)
63 | );
64 |
65 | CREATE TABLE IF NOT EXISTS wallet(
66 | id varchar(100) UNIQUE PRIMARY KEY,
67 | type varchar(25),
68 | address varchar(50)
69 | );
70 |
71 | CREATE TABLE IF NOT EXISTS activitystream(
72 | actor varchar(100) default '',
73 | attachment varchar(100) default '',
74 | attributedTo varchar(100) default '',
75 | audience varchar(100) default '',
76 | bcc varchar(100) default '',
77 | bto varchar(100) default '',
78 | cc varchar(100) default '',
79 | context varchar(100) default '',
80 | current varchar(100) default '',
81 | first varchar(100) default '',
82 | generator varchar(100) default '',
83 | icon varchar(100) default '',
84 | id varchar(100) UNIQUE PRIMARY KEY,
85 | image varchar(100) default '',
86 | instrument varchar(100) default '',
87 | last varchar(100) default '',
88 | location varchar(100) default '',
89 | items varchar(100) default '',
90 | oneOf varchar(100) default '',
91 | anyOf varchar(100) default '',
92 | closed varchar(100) default '',
93 | origin varchar(100) default '',
94 | next varchar(100) default '',
95 | object varchar(100),
96 | prev varchar(100) default '',
97 | preview varchar(100) default '',
98 | result varchar(100) default '',
99 | tag varchar(100) default '',
100 | target varchar(100) default '',
101 | type varchar(100) default '',
102 | to_ varchar(100) default '',
103 | url varchar(100) default '',
104 | accuracy varchar(100) default '',
105 | altitude varchar(100) default '',
106 | content varchar(2000) default '',
107 | name varchar(100) default '',
108 | alias varchar(100) default '',
109 | duration varchar(100) default '',
110 | height varchar(100) default '',
111 | href varchar(100) default '',
112 | hreflang varchar(100) default '',
113 | partOf varchar(100) default '',
114 | latitude varchar(100) default '',
115 | longitude varchar(100) default '',
116 | mediaType varchar(100) default '',
117 | endTime varchar(100) default '',
118 | published TIMESTAMP default NOW(),
119 | startTime varchar(100) default '',
120 | radius varchar(100) default '',
121 | rel varchar(100) default '',
122 | startIndex varchar(100) default '',
123 | summary varchar(100) default '',
124 | totalItems varchar(100) default '',
125 | units varchar(100) default '',
126 | updated TIMESTAMP default NOW(),
127 | deleted TIMESTAMP default NULL,
128 | width varchar(100) default '',
129 | subject varchar(100) default '',
130 | relationship varchar(100) default '',
131 | describes varchar(100) default '',
132 | formerType varchar(100) default '',
133 | size int default NULL,
134 | public boolean default false,
135 | CONSTRAINT fk_object FOREIGN KEY (object) REFERENCES activitystream(id)
136 | );
137 |
138 | CREATE TABLE IF NOT EXISTS cacheactivitystream(
139 | actor varchar(100) default '',
140 | attachment varchar(100) default '',
141 | attributedTo varchar(100) default '',
142 | audience varchar(100) default '',
143 | bcc varchar(100) default '',
144 | bto varchar(100) default '',
145 | cc varchar(100) default '',
146 | context varchar(100) default '',
147 | current varchar(100) default '',
148 | first varchar(100) default '',
149 | generator varchar(100) default '',
150 | icon varchar(100) default '',
151 | id varchar(100) UNIQUE PRIMARY KEY,
152 | image varchar(100) default '',
153 | instrument varchar(100) default '',
154 | last varchar(100) default '',
155 | location varchar(100) default '',
156 | items varchar(100) default '',
157 | oneOf varchar(100) default '',
158 | anyOf varchar(100) default '',
159 | closed varchar(100) default '',
160 | origin varchar(100) default '',
161 | next varchar(100) default '',
162 | object varchar(100),
163 | prev varchar(100) default '',
164 | preview varchar(100) default '',
165 | result varchar(100) default '',
166 | tag varchar(100) default '',
167 | target varchar(100) default '',
168 | type varchar(100) default '',
169 | to_ varchar(100) default '',
170 | url varchar(100) default '',
171 | accuracy varchar(100) default '',
172 | altitude varchar(100) default '',
173 | content varchar(2000) default '',
174 | name varchar(100) default '',
175 | alias varchar(100) default '',
176 | duration varchar(100) default '',
177 | height varchar(100) default '',
178 | href varchar(100) default '',
179 | hreflang varchar(100) default '',
180 | partOf varchar(100) default '',
181 | latitude varchar(100) default '',
182 | longitude varchar(100) default '',
183 | mediaType varchar(100) default '',
184 | endTime varchar(100) default '',
185 | published TIMESTAMP default NOW(),
186 | startTime varchar(100) default '',
187 | radius varchar(100) default '',
188 | rel varchar(100) default '',
189 | startIndex varchar(100) default '',
190 | summary varchar(100) default '',
191 | totalItems varchar(100) default '',
192 | units varchar(100) default '',
193 | updated TIMESTAMP default NOW(),
194 | deleted TIMESTAMP default NULL,
195 | width varchar(100) default '',
196 | subject varchar(100) default '',
197 | relationship varchar(100) default '',
198 | describes varchar(100) default '',
199 | formerType varchar(100) default '',
200 | size int default NULL,
201 | public boolean default false,
202 | CONSTRAINT fk_object FOREIGN KEY (object) REFERENCES cacheactivitystream(id)
203 | );
204 |
205 | CREATE TABLE IF NOT EXISTS removed(
206 | id varchar(100),
207 | type varchar(25)
208 | );
209 |
210 | ALTER TABLE activitystream ADD COLUMN IF NOT EXISTS tripcode varchar(50) default '';
211 | ALTER TABLE cacheactivitystream ADD COLUMN IF NOT EXISTS tripcode varchar(50) default '';
212 |
213 | CREATE TABLE IF NOT EXISTS publicKeyPem(
214 | id varchar(100) UNIQUE,
215 | owner varchar(100),
216 | file varchar(100)
217 | );
218 |
219 | CREATE TABLE IF NOT EXISTS newsItem(
220 | title text,
221 | content text,
222 | time bigint
223 | );
224 |
225 | ALTER TABLE actor ADD COLUMN IF NOT EXISTS publicKeyPem varchar(100) default '';
226 |
227 | ALTER TABLE activitystream ADD COLUMN IF NOT EXISTS sensitive boolean default false;
228 | ALTER TABLE cacheactivitystream ADD COLUMN IF NOT EXISTS sensitive boolean default false;
229 |
230 | CREATE TABLE IF NOT EXISTS postblacklist(
231 | id serial primary key,
232 | regex varchar(200)
233 | );
234 |
235 | ALTER TABLE actor ADD COLUMN IF NOT EXISTS autosubscribe boolean default false;
236 |
237 | CREATE TABLE IF NOT EXISTS bannedmedia(
238 | id serial primary key,
239 | hash varchar(200)
240 | );
241 |
242 | CREATE TABLE IF NOT EXISTS inactive(
243 | instance varchar(100) primary key,
244 | timestamp TIMESTAMP default NOW()
245 | );
246 |
247 | ALTER TABLE boardaccess ADD COLUMN IF NOT EXISTS label varchar(50) default 'Anon';
248 |
249 | CREATE TABLE IF NOT EXISTS sticky(
250 | actor_id varchar(100),
251 | activity_id varchar(100)
252 | );
253 |
254 | CREATE TABLE IF NOT EXISTS locked(
255 | actor_id varchar(100),
256 | activity_id varchar(100)
257 | );
258 |
259 | ALTER TABLE activitystream ALTER COLUMN content TYPE varchar(4500);
260 | ALTER TABLE cacheactivitystream ALTER COLUMN content TYPE varchar(4500);
--------------------------------------------------------------------------------
/db/database.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "database/sql"
5 | "fmt"
6 | "html/template"
7 | "io/ioutil"
8 | "os"
9 | "regexp"
10 | "strings"
11 | "time"
12 |
13 | "github.com/FChannel0/FChannel-Server/activitypub"
14 | "github.com/FChannel0/FChannel-Server/config"
15 | "github.com/FChannel0/FChannel-Server/util"
16 | _ "github.com/lib/pq"
17 | )
18 |
19 | type NewsItem struct {
20 | Title string
21 | Content template.HTML
22 | Time int
23 | }
24 |
25 | func Connect() error {
26 | host := config.DBHost
27 | port := config.DBPort
28 | user := config.DBUser
29 | password := config.DBPassword
30 | dbname := config.DBName
31 |
32 | psqlInfo := fmt.Sprintf("host=%s port=%d user=%s password=%s "+
33 | "dbname=%s sslmode=disable", host, port, user, password, dbname)
34 |
35 | _db, err := sql.Open("postgres", psqlInfo)
36 |
37 | if err != nil {
38 | return util.MakeError(err, "Connect")
39 | }
40 |
41 | if err := _db.Ping(); err != nil {
42 | return util.MakeError(err, "Connect")
43 | }
44 |
45 | config.Log.Println("Successfully connected DB")
46 |
47 | config.DB = _db
48 |
49 | return nil
50 | }
51 |
52 | func Close() error {
53 | err := config.DB.Close()
54 |
55 | return util.MakeError(err, "Close")
56 | }
57 |
58 | func RunDatabaseSchema() error {
59 | query, err := ioutil.ReadFile("databaseschema.psql")
60 | if err != nil {
61 | return util.MakeError(err, "RunDatabaseSchema")
62 | }
63 |
64 | _, err = config.DB.Exec(string(query))
65 | return util.MakeError(err, "RunDatabaseSchema")
66 | }
67 |
68 | func CreateNewBoard(actor activitypub.Actor) (activitypub.Actor, error) {
69 | if _, err := activitypub.GetActorFromDB(actor.Id); err == nil {
70 | return activitypub.Actor{}, util.MakeError(err, "CreateNewBoardDB")
71 | } else {
72 | query := `insert into actor (type, id, name, preferedusername, inbox, outbox, following, followers, summary, restricted) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`
73 | _, err := config.DB.Exec(query, actor.Type, actor.Id, actor.Name, actor.PreferredUsername, actor.Inbox, actor.Outbox, actor.Following, actor.Followers, actor.Summary, actor.Restricted)
74 |
75 | if err != nil {
76 | return activitypub.Actor{}, util.MakeError(err, "CreateNewBoardDB")
77 | }
78 |
79 | config.Log.Println("board added")
80 |
81 | for _, e := range actor.AuthRequirement {
82 | query = `insert into actorauth (type, board) values ($1, $2)`
83 | if _, err := config.DB.Exec(query, e, actor.Name); err != nil {
84 | return activitypub.Actor{}, util.MakeError(err, "CreateNewBoardDB")
85 | }
86 | }
87 |
88 | if actor.Id == config.Domain {
89 | var verify util.Verify
90 | verify.Type = "admin"
91 | verify.Identifier = actor.Id
92 |
93 | if err := actor.CreateVerification(verify); err != nil {
94 | return activitypub.Actor{}, util.MakeError(err, "CreateNewBoardDB")
95 | }
96 | }
97 |
98 | activitypub.CreatePem(actor)
99 |
100 | if actor.Name != "main" {
101 | var nObject activitypub.ObjectBase
102 | var nActivity activitypub.Activity
103 |
104 | nActor, err := activitypub.GetActorFromDB(config.Domain)
105 |
106 | if err != nil {
107 | return actor, util.MakeError(err, "CreateNewBoardDB")
108 | }
109 |
110 | nActivity.AtContext.Context = "https://www.w3.org/ns/activitystreams"
111 | nActivity.Type = "Follow"
112 | nActivity.Actor = &nActor
113 | nActivity.Object = nObject
114 | mActor, err := activitypub.GetActorFromDB(actor.Id)
115 |
116 | if err != nil {
117 | return actor, util.MakeError(err, "CreateNewBoardDB")
118 | }
119 |
120 | nActivity.Object.Actor = mActor.Id
121 | nActivity.To = append(nActivity.To, actor.Id)
122 |
123 | activityRequest := nActivity.AcceptFollow()
124 |
125 | if _, err := activityRequest.SetActorFollowing(); err != nil {
126 | return actor, util.MakeError(err, "CreateNewBoardDB")
127 | }
128 |
129 | if err := activityRequest.MakeRequestInbox(); err != nil {
130 | return actor, util.MakeError(err, "CreateNewBoardDB")
131 | }
132 | }
133 | }
134 |
135 | return actor, nil
136 | }
137 |
138 | func RemovePreviewFromFile(id string) error {
139 | var href string
140 |
141 | query := `select href from activitystream where id in (select preview from activitystream where id=$1)`
142 | if err := config.DB.QueryRow(query, id).Scan(&href); err != nil {
143 | return nil
144 | }
145 |
146 | href = strings.Replace(href, config.Domain+"/", "", 1)
147 |
148 | if href != "static/notfound.png" {
149 | if _, err := os.Stat(href); err != nil {
150 | return util.MakeError(err, "RemovePreviewFromFile")
151 | }
152 |
153 | err := os.Remove(href)
154 | return util.MakeError(err, "RemovePreviewFromFile")
155 | }
156 |
157 | obj := activitypub.ObjectBase{Id: id}
158 | err := obj.DeletePreview()
159 | return util.MakeError(err, "RemovePreviewFromFile")
160 | }
161 |
162 | //if limit less than 1 return all news items
163 | func GetNews(limit int) ([]NewsItem, error) {
164 | var news []NewsItem
165 | var query string
166 |
167 | var rows *sql.Rows
168 | var err error
169 |
170 | if limit > 0 {
171 | query = `select title, content, time from newsItem order by time desc limit $1`
172 | rows, err = config.DB.Query(query, limit)
173 | } else {
174 | query = `select title, content, time from newsItem order by time desc`
175 | rows, err = config.DB.Query(query)
176 | }
177 |
178 | if err != nil {
179 | return news, util.MakeError(err, "GetNews")
180 | }
181 |
182 | defer rows.Close()
183 | for rows.Next() {
184 | var content string
185 | n := NewsItem{}
186 |
187 | if err := rows.Scan(&n.Title, &content, &n.Time); err != nil {
188 | return news, util.MakeError(err, "GetNews")
189 | }
190 |
191 | content = strings.ReplaceAll(content, "\n", "
")
192 | n.Content = template.HTML(content)
193 |
194 | news = append(news, n)
195 | }
196 |
197 | return news, nil
198 | }
199 |
200 | func GetNewsItem(timestamp int) (NewsItem, error) {
201 | var news NewsItem
202 | var content string
203 |
204 | query := `select title, content, time from newsItem where time=$1 limit 1`
205 | if err := config.DB.QueryRow(query, timestamp).Scan(&news.Title, &content, &news.Time); err != nil {
206 | return news, util.MakeError(err, "GetNewsItem")
207 | }
208 |
209 | content = strings.ReplaceAll(content, "\n", "
")
210 | news.Content = template.HTML(content)
211 |
212 | return news, nil
213 | }
214 |
215 | func DeleteNewsItem(timestamp int) error {
216 | query := `delete from newsItem where time=$1`
217 | _, err := config.DB.Exec(query, timestamp)
218 |
219 | return util.MakeError(err, "DeleteNewsItem")
220 | }
221 |
222 | func WriteNews(news NewsItem) error {
223 | query := `insert into newsItem (title, content, time) values ($1, $2, $3)`
224 | _, err := config.DB.Exec(query, news.Title, news.Content, time.Now().Unix())
225 |
226 | return util.MakeError(err, "WriteNews")
227 | }
228 |
229 | func AddInstanceToInactive(instance string) error {
230 | var timeStamp string
231 |
232 | query := `select timestamp from inactive where instance=$1`
233 | if err := config.DB.QueryRow(query, instance).Scan(&timeStamp); err != nil {
234 | query := `insert into inactive (instance, timestamp) values ($1, $2)`
235 | _, err := config.DB.Exec(query, instance, time.Now().UTC().Format(time.RFC3339))
236 |
237 | return util.MakeError(err, "AddInstanceToInactive")
238 | }
239 |
240 | if !IsInactiveTimestamp(timeStamp) {
241 | return nil
242 | }
243 |
244 | query = `delete from follower where follower like $1`
245 | if _, err := config.DB.Exec(query, "%"+instance+"%"); err != nil {
246 | return util.MakeError(err, "AddInstanceToInactive")
247 | }
248 |
249 | err := DeleteInstanceFromInactive(instance)
250 | return util.MakeError(err, "AddInstanceToInactive")
251 | }
252 |
253 | func DeleteInstanceFromInactive(instance string) error {
254 | query := `delete from inactive where instance=$1`
255 | _, err := config.DB.Exec(query, instance)
256 |
257 | return util.MakeError(err, "DeleteInstanceFromInactive")
258 | }
259 |
260 | func IsInactiveTimestamp(timeStamp string) bool {
261 | stamp, _ := time.Parse(time.RFC3339, timeStamp)
262 |
263 | if time.Now().UTC().Sub(stamp).Hours() > 48 {
264 | return true
265 | }
266 |
267 | return false
268 | }
269 |
270 | func IsReplyToOP(op string, link string) (string, bool, error) {
271 | var id string
272 |
273 | if op == link {
274 | return link, true, nil
275 | }
276 |
277 | re := regexp.MustCompile(`f(\w+)\-`)
278 | match := re.FindStringSubmatch(link)
279 |
280 | if len(match) > 0 {
281 | re := regexp.MustCompile(`(.+)\-`)
282 | link = re.ReplaceAllString(link, "")
283 | link = "%" + match[1] + "/" + link
284 | }
285 |
286 | query := `select id from replies where id like $1 and inreplyto=$2`
287 | if err := config.DB.QueryRow(query, link, op).Scan(&id); err != nil {
288 | return op, false, nil
289 | }
290 |
291 | return id, id != "", nil
292 | }
293 |
294 | func GetReplyOP(link string) (string, error) {
295 | var id string
296 |
297 | query := `select id from replies where id in (select inreplyto from replies where id=$1) and inreplyto=''`
298 | if err := config.DB.QueryRow(query, link).Scan(&id); err != nil {
299 | return "", nil
300 | }
301 |
302 | return id, nil
303 | }
304 |
305 | func CheckInactive() {
306 | for true {
307 | CheckInactiveInstances()
308 | time.Sleep(24 * time.Hour)
309 | }
310 | }
311 |
312 | func CheckInactiveInstances() (map[string]string, error) {
313 | var rows *sql.Rows
314 | var err error
315 |
316 | instances := make(map[string]string)
317 |
318 | query := `select following from following`
319 | if rows, err = config.DB.Query(query); err != nil {
320 | return instances, util.MakeError(err, "CheckInactiveInstances")
321 | }
322 |
323 | defer rows.Close()
324 | for rows.Next() {
325 | var instance string
326 |
327 | if err := rows.Scan(&instance); err != nil {
328 | return instances, util.MakeError(err, "CheckInactiveInstances")
329 | }
330 |
331 | instances[instance] = instance
332 | }
333 |
334 | query = `select follower from follower`
335 | if rows, err = config.DB.Query(query); err != nil {
336 | return instances, util.MakeError(err, "CheckInactiveInstances")
337 | }
338 |
339 | defer rows.Close()
340 | for rows.Next() {
341 | var instance string
342 |
343 | if err := rows.Scan(&instance); err != nil {
344 | return instances, util.MakeError(err, "CheckInactiveInstances")
345 | }
346 |
347 | instances[instance] = instance
348 | }
349 |
350 | re := regexp.MustCompile(config.Domain + `(.+)?`)
351 |
352 | for _, e := range instances {
353 | actor, err := activitypub.GetActor(e)
354 |
355 | if err != nil {
356 | return instances, util.MakeError(err, "CheckInactiveInstances")
357 | }
358 |
359 | if actor.Id == "" && !re.MatchString(e) {
360 | if err := AddInstanceToInactive(e); err != nil {
361 | return instances, util.MakeError(err, "CheckInactiveInstances")
362 | }
363 | } else {
364 | if err := DeleteInstanceFromInactive(e); err != nil {
365 | return instances, util.MakeError(err, "CheckInactiveInstances")
366 | }
367 | }
368 | }
369 |
370 | return instances, nil
371 | }
372 |
373 | func GetAdminAuth() (string, string, error) {
374 | var code string
375 | var identifier string
376 |
377 | query := `select identifier, code from boardaccess where board=$1 and type='admin'`
378 | if err := config.DB.QueryRow(query, config.Domain).Scan(&identifier, &code); err != nil {
379 | return "", "", nil
380 | }
381 |
382 | return code, identifier, nil
383 | }
384 |
385 | func IsHashBanned(hash string) (bool, error) {
386 | var h string
387 |
388 | query := `select hash from bannedmedia where hash=$1`
389 | _ = config.DB.QueryRow(query, hash).Scan(&h)
390 |
391 | return h == hash, nil
392 | }
393 |
394 | func PrintAdminAuth() error {
395 | code, identifier, err := GetAdminAuth()
396 |
397 | if err != nil {
398 | return util.MakeError(err, "PrintAdminAuth")
399 | }
400 |
401 | config.Log.Println("Mod key: " + config.Key)
402 | config.Log.Println("Admin Login: " + identifier + ", Code: " + code)
403 | return nil
404 | }
405 |
406 | func InitInstance() error {
407 | if config.InstanceName != "" {
408 | if _, err := CreateNewBoard(*activitypub.CreateNewActor("", config.InstanceName, config.InstanceSummary, config.AuthReq, false)); err != nil {
409 | return util.MakeError(err, "InitInstance")
410 | }
411 | }
412 |
413 | return nil
414 | }
415 |
416 | func GetPostIDFromNum(num string) (string, error) {
417 | var postID string
418 |
419 | query := `select id from activitystream where id like $1`
420 | if err := config.DB.QueryRow(query, "%"+num).Scan(&postID); err != nil {
421 | query = `select id from cacheactivitystream where id like $1`
422 | if err := config.DB.QueryRow(query, "%"+num).Scan(&postID); err != nil {
423 | return "", util.MakeError(err, "GetPostIDFromNum")
424 | }
425 | }
426 |
427 | return postID, nil
428 | }
429 |
--------------------------------------------------------------------------------
/db/report.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "github.com/FChannel0/FChannel-Server/activitypub"
5 | "github.com/FChannel0/FChannel-Server/config"
6 | "github.com/FChannel0/FChannel-Server/util"
7 | )
8 |
9 | type Reports struct {
10 | ID string
11 | Count int
12 | Actor activitypub.Actor
13 | Object activitypub.ObjectBase
14 | OP string
15 | Reason []string
16 | }
17 |
18 | type Report struct {
19 | ID string
20 | Reason string
21 | }
22 |
23 | type Removed struct {
24 | ID string
25 | Type string
26 | Board string
27 | }
28 |
29 | func CloseLocalReport(id string, board string) error {
30 | query := `delete from reported where id=$1 and board=$2`
31 | _, err := config.DB.Exec(query, id, board)
32 |
33 | return util.MakeError(err, "CloseLocalReportDB")
34 | }
35 |
36 | func CreateLocalDelete(id string, _type string) error {
37 | var i string
38 |
39 | query := `select id from removed where id=$1`
40 | if err := config.DB.QueryRow(query, id).Scan(&i); err != nil {
41 | query := `insert into removed (id, type) values ($1, $2)`
42 | if _, err := config.DB.Exec(query, id, _type); err != nil {
43 | return util.MakeError(err, "CreateLocalDeleteDB")
44 | }
45 | }
46 |
47 | query = `update removed set type=$1 where id=$2`
48 | _, err := config.DB.Exec(query, _type, id)
49 |
50 | return util.MakeError(err, "CreateLocalDeleteDB")
51 | }
52 |
53 | func CreateLocalReport(id string, board string, reason string) error {
54 | query := `insert into reported (id, count, board, reason) values ($1, $2, $3, $4)`
55 | _, err := config.DB.Exec(query, id, 1, board, reason)
56 |
57 | return util.MakeError(err, "CreateLocalReportDB")
58 | }
59 |
60 | func GetLocalDelete() ([]Removed, error) {
61 | var deleted []Removed
62 |
63 | query := `select id, type from removed`
64 | rows, err := config.DB.Query(query)
65 |
66 | if err != nil {
67 | return deleted, util.MakeError(err, "GetLocalDeleteDB")
68 | }
69 |
70 | defer rows.Close()
71 | for rows.Next() {
72 | var r Removed
73 |
74 | if err := rows.Scan(&r.ID, &r.Type); err != nil {
75 | return deleted, util.MakeError(err, "GetLocalDeleteDB")
76 | }
77 |
78 | deleted = append(deleted, r)
79 | }
80 |
81 | return deleted, nil
82 | }
83 |
84 | func GetLocalReport(board string) (map[string]Reports, error) {
85 | var reported = make(map[string]Reports)
86 |
87 | query := `select id, reason from reported where board=$1`
88 | rows, err := config.DB.Query(query, board)
89 |
90 | if err != nil {
91 | return reported, util.MakeError(err, "GetLocalReportDB")
92 | }
93 |
94 | defer rows.Close()
95 | for rows.Next() {
96 | var r Report
97 |
98 | if err := rows.Scan(&r.ID, &r.Reason); err != nil {
99 | return reported, util.MakeError(err, "GetLocalReportDB")
100 | }
101 |
102 | if report, has := reported[r.ID]; has {
103 | report.Count += 1
104 | report.Reason = append(report.Reason, r.Reason)
105 | reported[r.ID] = report
106 | continue
107 | }
108 |
109 | var obj = activitypub.ObjectBase{Id: r.ID}
110 |
111 | col, _ := obj.GetCollectionFromPath()
112 |
113 | if len(col.OrderedItems) == 0 {
114 | continue
115 | }
116 |
117 | OP, _ := obj.GetOP()
118 |
119 | reported[r.ID] = Reports{
120 | ID: r.ID,
121 | Count: 1,
122 | Object: col.OrderedItems[0],
123 | OP: OP,
124 | Actor: activitypub.Actor{Name: board, Outbox: config.Domain + "/" + board + "/outbox"},
125 | Reason: []string{r.Reason},
126 | }
127 | }
128 |
129 | return reported, nil
130 | }
131 |
132 | type ReportsSortDesc []Reports
133 |
134 | func (a ReportsSortDesc) Len() int { return len(a) }
135 | func (a ReportsSortDesc) Less(i, j int) bool { return a[i].Object.Updated.After(a[j].Object.Updated) }
136 | func (a ReportsSortDesc) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
137 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | postgres:
4 | image: postgres:13.4-alpine
5 | restart: unless-stopped
6 | environment:
7 | POSTGRES_USER: fchan
8 | POSTGRES_PASSWORD: hackme
9 | POSTGRES_DB: fchan
10 | volumes:
11 | - ./pgdata:/var/lib/postgresql/data
12 | fchan:
13 | build: ./
14 | restart: unless-stopped
15 | volumes:
16 | - ./config:/app/config
17 | - ./public/:/app/public/
18 | - ./pem/:/app/pem/
19 | ports:
20 | - "3000:3000"
21 | links:
22 | - postgres
23 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/FChannel0/FChannel-Server
2 |
3 | go 1.15
4 |
5 | require (
6 | github.com/gofiber/fiber/v2 v2.20.2
7 | github.com/gofiber/template v1.6.18
8 | github.com/lib/pq v1.9.0
9 | github.com/simia-tech/crypt v0.5.0
10 | golang.org/x/text v0.3.6
11 | )
12 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "math/rand"
5 | "time"
6 |
7 | "github.com/FChannel0/FChannel-Server/activitypub"
8 | "github.com/FChannel0/FChannel-Server/config"
9 | "github.com/FChannel0/FChannel-Server/db"
10 | "github.com/FChannel0/FChannel-Server/route"
11 | "github.com/FChannel0/FChannel-Server/route/routes"
12 | "github.com/FChannel0/FChannel-Server/util"
13 | "github.com/FChannel0/FChannel-Server/webfinger"
14 | "github.com/gofiber/fiber/v2"
15 | "github.com/gofiber/fiber/v2/middleware/encryptcookie"
16 | "github.com/gofiber/fiber/v2/middleware/logger"
17 | "github.com/gofiber/template/html"
18 | )
19 |
20 | func main() {
21 |
22 | Init()
23 |
24 | defer db.Close()
25 |
26 | // Routing and templates
27 | template := html.New("./views", ".html")
28 |
29 | route.TemplateFunctions(template)
30 |
31 | app := fiber.New(fiber.Config{
32 | AppName: "FChannel",
33 | Views: template,
34 | ReadTimeout: 30 * time.Second,
35 | WriteTimeout: 30 * time.Second,
36 | IdleTimeout: 60 * time.Second,
37 | ServerHeader: "FChannel/" + config.InstanceName,
38 | })
39 |
40 | app.Use(logger.New())
41 |
42 | cookieKey, err := util.GetCookieKey()
43 |
44 | if err != nil {
45 | config.Log.Println(err)
46 | }
47 |
48 | app.Use(encryptcookie.New(encryptcookie.Config{
49 | Key: cookieKey,
50 | }))
51 |
52 | app.Static("/static", "./views")
53 | app.Static("/public", "./public")
54 |
55 | // Main actor
56 | app.Get("/", routes.Index)
57 | app.Post("/inbox", routes.Inbox)
58 | app.Post("/outbox", routes.Outbox)
59 | app.Get("/following", routes.Following)
60 | app.Get("/followers", routes.Followers)
61 |
62 | // Admin routes
63 | app.All("/"+config.Key+"/", routes.AdminIndex)
64 | app.Post("/"+config.Key+"/verify", routes.AdminVerify)
65 | app.Post("/"+config.Key+"/auth", routes.AdminAuth)
66 | app.All("/"+config.Key+"/follow", routes.AdminFollow)
67 | app.Post("/"+config.Key+"/addboard", routes.AdminAddBoard)
68 | app.Post("/"+config.Key+"/newspost", routes.NewsPost)
69 | app.Get("/"+config.Key+"/newsdelete/:ts", routes.NewsDelete)
70 | app.Post("/"+config.Key+"/:actor/addjanny", routes.AdminAddJanny)
71 | app.Post("/"+config.Key+"/:actor/editsummary", routes.AdminEditSummary)
72 | app.Get("/"+config.Key+"/:actor/deletejanny", routes.AdminDeleteJanny)
73 | app.All("/"+config.Key+"/:actor/follow", routes.AdminFollow)
74 | app.Get("/"+config.Key+"/:actor", routes.AdminActorIndex)
75 |
76 | // News routes
77 | app.Get("/news/:ts", routes.NewsGet)
78 | app.Get("/news", routes.NewsGetAll)
79 |
80 | // Board managment
81 | app.Get("/banmedia", routes.BoardBanMedia)
82 | app.Get("/delete", routes.BoardDelete)
83 | app.Get("/deleteattach", routes.BoardDeleteAttach)
84 | app.Get("/marksensitive", routes.BoardMarkSensitive)
85 | app.Get("/addtoindex", routes.BoardAddToIndex)
86 | app.Get("/poparchive", routes.BoardPopArchive)
87 | app.Get("/autosubscribe", routes.BoardAutoSubscribe)
88 | app.All("/blacklist", routes.BoardBlacklist)
89 | app.All("/report", routes.ReportPost)
90 | app.Get("/make-report", routes.ReportGet)
91 | app.Get("/sticky", routes.Sticky)
92 | app.Get("/lock", routes.Lock)
93 |
94 | // Webfinger routes
95 | app.Get("/.well-known/webfinger", routes.Webfinger)
96 |
97 | // API routes
98 | app.Get("/api/media", routes.Media)
99 |
100 | // Board actor routes
101 | app.Post("/post", routes.MakeActorPost)
102 | app.Get("/:actor/catalog", routes.ActorCatalog)
103 | app.Post("/:actor/inbox", routes.ActorInbox)
104 | app.Get("/:actor/outbox", routes.GetActorOutbox)
105 | app.Post("/:actor/outbox", routes.PostActorOutbox)
106 | app.Get("/:actor/following", routes.ActorFollowing)
107 | app.Get("/:actor/followers", routes.ActorFollowers)
108 | app.Get("/:actor/archive", routes.ActorArchive)
109 | app.Get("/:actor", routes.ActorPosts)
110 | app.Get("/:actor/:post", routes.ActorPost)
111 |
112 | db.PrintAdminAuth()
113 |
114 | app.Listen(config.Port)
115 | }
116 |
117 | func Init() {
118 | var actor activitypub.Actor
119 | var err error
120 |
121 | rand.Seed(time.Now().UnixNano())
122 |
123 | if err = util.CreatedNeededDirectories(); err != nil {
124 | config.Log.Println(err)
125 | }
126 |
127 | if err = db.Connect(); err != nil {
128 | config.Log.Println(err)
129 | }
130 |
131 | if err = db.RunDatabaseSchema(); err != nil {
132 | config.Log.Println(err)
133 | }
134 |
135 | if err = db.InitInstance(); err != nil {
136 | config.Log.Println(err)
137 | }
138 |
139 | if actor, err = activitypub.GetActorFromDB(config.Domain); err != nil {
140 | config.Log.Println(err)
141 | }
142 |
143 | if webfinger.FollowingBoards, err = actor.GetFollowing(); err != nil {
144 | config.Log.Println(err)
145 | }
146 |
147 | if webfinger.Boards, err = webfinger.GetBoardCollection(); err != nil {
148 | config.Log.Println(err)
149 | }
150 |
151 | if config.Key == "" {
152 | if config.Key, err = util.CreateKey(32); err != nil {
153 | config.Log.Println(err)
154 | }
155 | }
156 |
157 | if err = util.LoadThemes(); err != nil {
158 | config.Log.Println(err)
159 | }
160 |
161 | go webfinger.StartupArchive()
162 |
163 | go util.MakeCaptchas(100)
164 |
165 | go db.CheckInactive()
166 | }
167 |
--------------------------------------------------------------------------------
/post/tripcode.go:
--------------------------------------------------------------------------------
1 | package post
2 |
3 | import (
4 | "bytes"
5 | "regexp"
6 | "strings"
7 |
8 | "github.com/FChannel0/FChannel-Server/config"
9 | "github.com/FChannel0/FChannel-Server/util"
10 | "github.com/gofiber/fiber/v2"
11 | _ "github.com/lib/pq"
12 | "github.com/simia-tech/crypt"
13 | "golang.org/x/text/encoding/japanese"
14 | "golang.org/x/text/transform"
15 | )
16 |
17 | const SaltTable = "" +
18 | "................................" +
19 | ".............../0123456789ABCDEF" +
20 | "GABCDEFGHIJKLMNOPQRSTUVWXYZabcde" +
21 | "fabcdefghijklmnopqrstuvwxyz....." +
22 | "................................" +
23 | "................................" +
24 | "................................" +
25 | "................................"
26 |
27 | func CreateNameTripCode(ctx *fiber.Ctx) (string, string, error) {
28 | input := ctx.FormValue("name")
29 | tripSecure := regexp.MustCompile("##(.+)?")
30 |
31 | if tripSecure.MatchString(input) {
32 | chunck := tripSecure.FindString(input)
33 | chunck = strings.Replace(chunck, "##", "", 1)
34 | ce := regexp.MustCompile(`(?i)Admin`)
35 | admin := ce.MatchString(chunck)
36 | board, modcred := util.GetPasswordFromSession(ctx)
37 |
38 | if hasAuth, _ := util.HasAuth(modcred, board); hasAuth && admin {
39 | return tripSecure.ReplaceAllString(input, ""), "#Admin", nil
40 | }
41 |
42 | hash, err := TripCodeSecure(chunck)
43 |
44 | return tripSecure.ReplaceAllString(input, ""), "!!" + hash, util.MakeError(err, "CreateNameTripCode")
45 | }
46 |
47 | trip := regexp.MustCompile("#(.+)?")
48 |
49 | if trip.MatchString(input) {
50 | chunck := trip.FindString(input)
51 | chunck = strings.Replace(chunck, "#", "", 1)
52 | ce := regexp.MustCompile(`(?i)Admin`)
53 | admin := ce.MatchString(chunck)
54 | board, modcred := util.GetPasswordFromSession(ctx)
55 |
56 | if hasAuth, _ := util.HasAuth(modcred, board); hasAuth && admin {
57 | return trip.ReplaceAllString(input, ""), "#Admin", nil
58 | }
59 |
60 | hash, err := TripCode(chunck)
61 | return trip.ReplaceAllString(input, ""), "!" + hash, util.MakeError(err, "CreateNameTripCode")
62 | }
63 |
64 | return input, "", nil
65 | }
66 |
67 | func TripCode(pass string) (string, error) {
68 | var salt [2]rune
69 |
70 | pass = TripCodeConvert(pass)
71 | s := []rune(pass + "H..")[1:3]
72 |
73 | for i, r := range s {
74 | salt[i] = rune(SaltTable[r%256])
75 | }
76 |
77 | enc, err := crypt.Crypt(pass, "$1$"+string(salt[:]))
78 |
79 | if err != nil {
80 | return "", util.MakeError(err, "TripCode")
81 | }
82 |
83 | // normally i would just return error here but if the encrypt fails, this operation may fail and as a result cause a panic
84 | return enc[len(enc)-10 : len(enc)], nil
85 | }
86 |
87 | func TripCodeConvert(str string) string {
88 | var s bytes.Buffer
89 |
90 | transform.NewWriter(&s, japanese.ShiftJIS.NewEncoder()).Write([]byte(str))
91 | re := strings.NewReplacer(
92 | "&", "&",
93 | "\"", """,
94 | "<", "<",
95 | ">", ">",
96 | )
97 |
98 | return re.Replace(s.String())
99 | }
100 |
101 | func TripCodeSecure(pass string) (string, error) {
102 | pass = TripCodeConvert(pass)
103 | enc, err := crypt.Crypt(pass, "$1$"+config.Salt)
104 |
105 | if err != nil {
106 | return "", util.MakeError(err, "TripCodeSecure")
107 | }
108 |
109 | return enc[len(enc)-10 : len(enc)], nil
110 | }
111 |
--------------------------------------------------------------------------------
/route/routes/admin.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "io/ioutil"
8 | "net/http"
9 | "regexp"
10 | "sort"
11 | "time"
12 |
13 | "github.com/FChannel0/FChannel-Server/activitypub"
14 | "github.com/FChannel0/FChannel-Server/config"
15 | "github.com/FChannel0/FChannel-Server/db"
16 | "github.com/FChannel0/FChannel-Server/route"
17 | "github.com/FChannel0/FChannel-Server/util"
18 | "github.com/FChannel0/FChannel-Server/webfinger"
19 | "github.com/gofiber/fiber/v2"
20 | )
21 |
22 | func AdminVerify(ctx *fiber.Ctx) error {
23 | identifier := ctx.FormValue("id")
24 | code := ctx.FormValue("code")
25 |
26 | var verify util.Verify
27 | verify.Identifier = identifier
28 | verify.Code = code
29 |
30 | j, _ := json.Marshal(&verify)
31 |
32 | req, err := http.NewRequest("POST", config.Domain+"/"+config.Key+"/auth", bytes.NewBuffer(j))
33 |
34 | if err != nil {
35 | return util.MakeError(err, "AdminVerify")
36 | }
37 |
38 | req.Header.Set("Content-Type", config.ActivityStreams)
39 |
40 | resp, err := http.DefaultClient.Do(req)
41 |
42 | if err != nil {
43 | return util.MakeError(err, "AdminVerify")
44 | }
45 |
46 | defer resp.Body.Close()
47 |
48 | rBody, _ := ioutil.ReadAll(resp.Body)
49 |
50 | body := string(rBody)
51 |
52 | if resp.StatusCode != 200 {
53 | return ctx.Redirect("/", http.StatusPermanentRedirect)
54 | }
55 |
56 | ctx.Cookie(&fiber.Cookie{
57 | Name: "session_token",
58 | Value: body + "|" + verify.Code,
59 | Expires: time.Now().UTC().Add(60 * 60 * 48 * time.Second),
60 | })
61 |
62 | return ctx.Redirect("/", http.StatusSeeOther)
63 | }
64 |
65 | // TODO remove this route it is mostly unneeded
66 | func AdminAuth(ctx *fiber.Ctx) error {
67 | var verify util.Verify
68 |
69 | err := json.Unmarshal(ctx.Body(), &verify)
70 |
71 | if err != nil {
72 | return util.MakeError(err, "AdminAuth")
73 | }
74 |
75 | v, _ := util.GetVerificationByCode(verify.Code)
76 |
77 | if v.Identifier == verify.Identifier {
78 | _, err := ctx.Write([]byte(v.Board))
79 | return util.MakeError(err, "AdminAuth")
80 | }
81 |
82 | ctx.Response().Header.SetStatusCode(http.StatusBadRequest)
83 | _, err = ctx.Write([]byte(""))
84 |
85 | return util.MakeError(err, "AdminAuth")
86 | }
87 |
88 | func AdminIndex(ctx *fiber.Ctx) error {
89 | id, _ := util.GetPasswordFromSession(ctx)
90 | actor, _ := webfinger.GetActorFromPath(ctx.Path(), "/"+config.Key+"/")
91 |
92 | if actor.Id == "" {
93 | actor, _ = activitypub.GetActorByNameFromDB(config.Domain)
94 | }
95 |
96 | if id == "" || (id != actor.Id && id != config.Domain) {
97 | return ctx.Render("verify", fiber.Map{"key": config.Key})
98 | }
99 |
100 | actor, err := activitypub.GetActor(config.Domain)
101 |
102 | if err != nil {
103 | return util.MakeError(err, "AdminIndex")
104 | }
105 |
106 | reqActivity := activitypub.Activity{Id: actor.Following}
107 | follow, _ := reqActivity.GetCollection()
108 | follower, _ := reqActivity.GetCollection()
109 |
110 | var following []string
111 | var followers []string
112 |
113 | for _, e := range follow.Items {
114 | following = append(following, e.Id)
115 | }
116 |
117 | for _, e := range follower.Items {
118 | followers = append(followers, e.Id)
119 | }
120 |
121 | var adminData route.AdminPage
122 | adminData.Following = following
123 | adminData.Followers = followers
124 |
125 | var reported = make(map[string][]db.Reports)
126 |
127 | for _, e := range following {
128 | re := regexp.MustCompile(`.*/(.+)$`)
129 | boards := re.FindStringSubmatch(e)
130 | reports, _ := db.GetLocalReport(boards[1])
131 |
132 | for _, k := range reports {
133 | reported[k.Actor.Name] = append(reported[k.Actor.Name], k)
134 | }
135 | }
136 |
137 | for k, e := range reported {
138 | sort.Sort(db.ReportsSortDesc(e))
139 | reported[k] = e
140 | }
141 |
142 | adminData.Actor = actor.Id
143 | adminData.Key = config.Key
144 | adminData.Domain = config.Domain
145 | adminData.Board.ModCred, _ = util.GetPasswordFromSession(ctx)
146 | adminData.Title = actor.Name + " Admin page"
147 |
148 | adminData.Boards = webfinger.Boards
149 |
150 | adminData.Board.Post.Actor = actor.Id
151 |
152 | adminData.Instance, _ = activitypub.GetActorFromDB(config.Domain)
153 |
154 | adminData.PostBlacklist, _ = util.GetRegexBlacklist()
155 |
156 | adminData.Meta.Description = adminData.Title
157 | adminData.Meta.Url = adminData.Board.Actor.Id
158 | adminData.Meta.Title = adminData.Title
159 |
160 | adminData.Themes = &config.Themes
161 |
162 | return ctx.Render("admin", fiber.Map{
163 | "page": adminData,
164 | "reports": reported,
165 | }, "layouts/main")
166 | }
167 |
168 | func AdminFollow(ctx *fiber.Ctx) error {
169 | follow := ctx.FormValue("follow")
170 | actorId := ctx.FormValue("actor")
171 |
172 | actor := activitypub.Actor{Id: actorId}
173 | followActivity, _ := actor.MakeFollowActivity(follow)
174 |
175 | objActor := activitypub.Actor{Id: followActivity.Object.Actor}
176 |
177 | if isLocal, _ := objActor.IsLocal(); !isLocal && followActivity.Actor.Id == config.Domain {
178 | _, err := ctx.Write([]byte("main board can only follow local boards. Create a new board and then follow outside boards from it."))
179 | return util.MakeError(err, "AdminIndex")
180 | }
181 |
182 | if actor, _ := activitypub.FingerActor(follow); actor.Id != "" {
183 | if err := followActivity.MakeRequestOutbox(); err != nil {
184 | return util.MakeError(err, "AdminFollow")
185 | }
186 | }
187 |
188 | var redirect string
189 | actor, _ = webfinger.GetActorFromPath(ctx.Path(), "/"+config.Key+"/")
190 |
191 | if actor.Name != "main" {
192 | redirect = actor.Name
193 | }
194 |
195 | time.Sleep(time.Duration(500) * time.Millisecond)
196 |
197 | return ctx.Redirect("/"+config.Key+"/"+redirect, http.StatusSeeOther)
198 | }
199 |
200 | func AdminAddBoard(ctx *fiber.Ctx) error {
201 | actor, _ := activitypub.GetActorFromDB(config.Domain)
202 |
203 | if hasValidation := actor.HasValidation(ctx); !hasValidation {
204 | return nil
205 | }
206 |
207 | var newActorActivity activitypub.Activity
208 | var board activitypub.Actor
209 |
210 | var restrict bool
211 | if ctx.FormValue("restricted") == "True" {
212 | restrict = true
213 | } else {
214 | restrict = false
215 | }
216 |
217 | board.Name = ctx.FormValue("name")
218 | board.PreferredUsername = ctx.FormValue("prefname")
219 | board.Summary = ctx.FormValue("summary")
220 | board.Restricted = restrict
221 |
222 | newActorActivity.AtContext.Context = "https://www.w3.org/ns/activitystreams"
223 | newActorActivity.Type = "New"
224 |
225 | var nobj activitypub.ObjectBase
226 | newActorActivity.Actor = &actor
227 | newActorActivity.Object = nobj
228 |
229 | newActorActivity.Object.Alias = board.Name
230 | newActorActivity.Object.Name = board.PreferredUsername
231 | newActorActivity.Object.Summary = board.Summary
232 | newActorActivity.Object.Sensitive = board.Restricted
233 |
234 | newActorActivity.MakeRequestOutbox()
235 |
236 | time.Sleep(time.Duration(500) * time.Millisecond)
237 |
238 | return ctx.Redirect("/"+config.Key, http.StatusSeeOther)
239 | }
240 |
241 | func AdminActorIndex(ctx *fiber.Ctx) error {
242 | var data route.AdminPage
243 |
244 | id, pass := util.GetPasswordFromSession(ctx)
245 | actor, _ := webfinger.GetActorFromPath(ctx.Path(), "/"+config.Key+"/")
246 |
247 | if actor.Id == "" {
248 | actor, _ = activitypub.GetActorByNameFromDB(config.Domain)
249 | }
250 |
251 | var hasAuth bool
252 | hasAuth, data.Board.ModCred = util.HasAuth(pass, actor.Id)
253 |
254 | if !hasAuth || (id != actor.Id && id != config.Domain) {
255 | return ctx.Render("verify", fiber.Map{"key": config.Key})
256 | }
257 |
258 | reqActivity := activitypub.Activity{Id: actor.Following}
259 | follow, _ := reqActivity.GetCollection()
260 |
261 | reqActivity.Id = actor.Followers
262 | follower, _ := reqActivity.GetCollection()
263 |
264 | var following []string
265 | var followers []string
266 |
267 | for _, e := range follow.Items {
268 | following = append(following, e.Id)
269 | }
270 |
271 | for _, e := range follower.Items {
272 | followers = append(followers, e.Id)
273 | }
274 |
275 | data.Following = following
276 | data.Followers = followers
277 |
278 | reports, _ := db.GetLocalReport(actor.Name)
279 |
280 | var reported = make(map[string][]db.Reports)
281 | for _, k := range reports {
282 | reported[k.Actor.Name] = append(reported[k.Actor.Name], k)
283 | }
284 |
285 | for k, e := range reported {
286 | sort.Sort(db.ReportsSortDesc(e))
287 | reported[k] = e
288 | }
289 |
290 | data.Domain = config.Domain
291 | data.IsLocal, _ = actor.IsLocal()
292 | data.Title = "Manage /" + actor.Name + "/"
293 | data.Boards = webfinger.Boards
294 | data.Board.Name = actor.Name
295 | data.Board.Actor = actor
296 | data.Key = config.Key
297 | data.Board.TP = config.TP
298 |
299 | data.Board.Post.Actor = actor.Id
300 |
301 | data.Instance, _ = activitypub.GetActorFromDB(config.Domain)
302 |
303 | data.AutoSubscribe, _ = actor.GetAutoSubscribe()
304 |
305 | jannies, err := actor.GetJanitors()
306 |
307 | if err != nil {
308 | return util.MakeError(err, "AdminActorIndex")
309 | }
310 |
311 | data.Meta.Description = data.Title
312 | data.Meta.Url = data.Board.Actor.Id
313 | data.Meta.Title = data.Title
314 |
315 | data.Themes = &config.Themes
316 |
317 | data.RecentPosts, _ = actor.GetRecentPosts()
318 |
319 | if cookie := ctx.Cookies("theme"); cookie != "" {
320 | data.ThemeCookie = cookie
321 | }
322 |
323 | return ctx.Render("manage", fiber.Map{
324 | "page": data,
325 | "jannies": jannies,
326 | "reports": reported,
327 | }, "layouts/main")
328 | }
329 |
330 | func AdminAddJanny(ctx *fiber.Ctx) error {
331 | id, pass := util.GetPasswordFromSession(ctx)
332 | actor, _ := webfinger.GetActorFromPath(ctx.Path(), "/"+config.Key+"/")
333 |
334 | if actor.Id == "" {
335 | actor, _ = activitypub.GetActorByNameFromDB(config.Domain)
336 | }
337 |
338 | hasAuth, _type := util.HasAuth(pass, actor.Id)
339 |
340 | if !hasAuth || _type != "admin" || (id != actor.Id && id != config.Domain) {
341 | return util.MakeError(errors.New("Error"), "AdminJanny")
342 | }
343 |
344 | var verify util.Verify
345 | verify.Type = "janitor"
346 | verify.Identifier = actor.Id
347 | verify.Label = ctx.FormValue("label")
348 |
349 | if err := actor.CreateVerification(verify); err != nil {
350 | return util.MakeError(err, "CreateNewBoardDB")
351 | }
352 |
353 | var redirect string
354 | actor, _ = webfinger.GetActorFromPath(ctx.Path(), "/"+config.Key+"/")
355 |
356 | if actor.Name != "main" {
357 | redirect = actor.Name
358 | }
359 |
360 | return ctx.Redirect("/"+config.Key+"/"+redirect, http.StatusSeeOther)
361 | }
362 |
363 | func AdminEditSummary(ctx *fiber.Ctx) error {
364 | id, pass := util.GetPasswordFromSession(ctx)
365 | actor, _ := webfinger.GetActorFromPath(ctx.Path(), "/"+config.Key+"/")
366 |
367 | if actor.Id == "" {
368 | actor, _ = activitypub.GetActorByNameFromDB(config.Domain)
369 | }
370 |
371 | hasAuth, _type := util.HasAuth(pass, actor.Id)
372 |
373 | if !hasAuth || _type != "admin" || (id != actor.Id && id != config.Domain) {
374 | return util.MakeError(errors.New("Error"), "AdminEditSummary")
375 | }
376 |
377 | summary := ctx.FormValue("summary")
378 |
379 | query := `update actor set summary=$1 where id=$2`
380 | if _, err := config.DB.Exec(query, summary, actor.Id); err != nil {
381 | return util.MakeError(err, "AdminEditSummary")
382 | }
383 |
384 | var redirect string
385 | if actor.Name != "main" {
386 | redirect = actor.Name
387 | }
388 |
389 | return ctx.Redirect("/"+config.Key+"/"+redirect, http.StatusSeeOther)
390 |
391 | }
392 |
393 | func AdminDeleteJanny(ctx *fiber.Ctx) error {
394 | id, pass := util.GetPasswordFromSession(ctx)
395 | actor, _ := webfinger.GetActorFromPath(ctx.Path(), "/"+config.Key+"/")
396 |
397 | if actor.Id == "" {
398 | actor, _ = activitypub.GetActorByNameFromDB(config.Domain)
399 | }
400 |
401 | hasAuth, _type := util.HasAuth(pass, actor.Id)
402 |
403 | if !hasAuth || _type != "admin" || (id != actor.Id && id != config.Domain) {
404 | return util.MakeError(errors.New("Error"), "AdminJanny")
405 | }
406 |
407 | var verify util.Verify
408 | verify.Code = ctx.Query("code")
409 |
410 | if err := actor.DeleteVerification(verify); err != nil {
411 | return util.MakeError(err, "AdminDeleteJanny")
412 | }
413 |
414 | var redirect string
415 |
416 | if actor.Name != "main" {
417 | redirect = actor.Name
418 | }
419 |
420 | return ctx.Redirect("/"+config.Key+"/"+redirect, http.StatusSeeOther)
421 | }
422 |
--------------------------------------------------------------------------------
/route/routes/api.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "io/ioutil"
5 | "net/http"
6 | "time"
7 |
8 | "github.com/FChannel0/FChannel-Server/config"
9 | "github.com/FChannel0/FChannel-Server/util"
10 | "github.com/gofiber/fiber/v2"
11 | )
12 |
13 | func Media(ctx *fiber.Ctx) error {
14 | if ctx.Query("hash") != "" {
15 | return RouteImages(ctx, ctx.Query("hash"))
16 | }
17 |
18 | return ctx.SendStatus(404)
19 | }
20 |
21 | func RouteImages(ctx *fiber.Ctx, media string) error {
22 | req, err := http.NewRequest("GET", config.MediaHashs[media], nil)
23 | if err != nil {
24 | return util.MakeError(err, "RouteImages")
25 | }
26 |
27 | client := http.Client{
28 | Timeout: 5 * time.Second,
29 | }
30 |
31 | resp, err := client.Do(req)
32 | if err != nil {
33 | return nil
34 | }
35 | defer resp.Body.Close()
36 |
37 | if resp.StatusCode != 200 {
38 | fileBytes, err := ioutil.ReadFile("./views/notfound.png")
39 | if err != nil {
40 | return util.MakeError(err, "RouteImages")
41 | }
42 |
43 | _, err = ctx.Write(fileBytes)
44 | return util.MakeError(err, "RouteImages")
45 | }
46 |
47 | body, _ := ioutil.ReadAll(resp.Body)
48 | for name, values := range resp.Header {
49 | for _, value := range values {
50 | ctx.Append(name, value)
51 | }
52 | }
53 |
54 | return ctx.Send(body)
55 | }
56 |
--------------------------------------------------------------------------------
/route/routes/main.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "github.com/FChannel0/FChannel-Server/activitypub"
5 | "github.com/FChannel0/FChannel-Server/config"
6 | "github.com/FChannel0/FChannel-Server/db"
7 | "github.com/FChannel0/FChannel-Server/route"
8 | "github.com/FChannel0/FChannel-Server/util"
9 | "github.com/FChannel0/FChannel-Server/webfinger"
10 | "github.com/gofiber/fiber/v2"
11 | )
12 |
13 | func Index(ctx *fiber.Ctx) error {
14 | actor, err := activitypub.GetActorFromDB(config.Domain)
15 | if err != nil {
16 | return util.MakeError(err, "Index")
17 | }
18 |
19 | // this is a activitpub json request return json instead of html page
20 | if activitypub.AcceptActivity(ctx.Get("Accept")) {
21 | actor.GetInfoResp(ctx)
22 | return nil
23 | }
24 |
25 | var data route.PageData
26 |
27 | data.NewsItems, err = db.GetNews(3)
28 | if err != nil {
29 | return util.MakeError(err, "Index")
30 | }
31 |
32 | data.Title = "Welcome to " + actor.PreferredUsername
33 | data.PreferredUsername = actor.PreferredUsername
34 | data.Boards = webfinger.Boards
35 | data.Board.Name = ""
36 | data.Key = config.Key
37 | data.Board.Domain = config.Domain
38 | data.Board.ModCred, _ = util.GetPasswordFromSession(ctx)
39 | data.Board.Actor = actor
40 | data.Board.Post.Actor = actor.Id
41 | data.Board.Restricted = actor.Restricted
42 | //almost certainly there is a better algorithm for this but the old one was wrong
43 | //and I suck at math. This works at least.
44 | data.BoardRemainer = make([]int, 3-(len(data.Boards)%3))
45 |
46 | if len(data.BoardRemainer) == 3 {
47 | data.BoardRemainer = make([]int, 0)
48 | }
49 |
50 | data.Meta.Description = data.PreferredUsername + " a federated image board based on ActivityPub. The current version of the code running on the server is still a work-in-progress product, expect a bumpy ride for the time being. Get the server code here: https://github.com/FChannel0."
51 | data.Meta.Url = data.Board.Domain
52 | data.Meta.Title = data.Title
53 |
54 | data.Themes = &config.Themes
55 | data.ThemeCookie = route.GetThemeCookie(ctx)
56 |
57 | return ctx.Render("index", fiber.Map{
58 | "page": data,
59 | }, "layouts/main")
60 | }
61 |
62 | func Inbox(ctx *fiber.Ctx) error {
63 | // TODO main actor Inbox route
64 | return ctx.SendString("main inbox")
65 | }
66 |
67 | func Outbox(ctx *fiber.Ctx) error {
68 | actor, err := webfinger.GetActorFromPath(ctx.Path(), "/")
69 |
70 | if err != nil {
71 | return util.MakeError(err, "Outbox")
72 | }
73 |
74 | if activitypub.AcceptActivity(ctx.Get("Accept")) {
75 | actor.GetOutbox(ctx)
76 | return nil
77 | }
78 |
79 | return route.ParseOutboxRequest(ctx, actor)
80 | }
81 |
82 | func Following(ctx *fiber.Ctx) error {
83 | actor, _ := activitypub.GetActorFromDB(config.Domain)
84 | return actor.GetFollowingResp(ctx)
85 | }
86 |
87 | func Followers(ctx *fiber.Ctx) error {
88 | actor, _ := activitypub.GetActorFromDB(config.Domain)
89 | return actor.GetFollowersResp(ctx)
90 | }
91 |
--------------------------------------------------------------------------------
/route/routes/news.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "html/template"
5 | "net/http"
6 | "strconv"
7 |
8 | "github.com/FChannel0/FChannel-Server/activitypub"
9 | "github.com/FChannel0/FChannel-Server/config"
10 | "github.com/FChannel0/FChannel-Server/db"
11 | "github.com/FChannel0/FChannel-Server/route"
12 | "github.com/FChannel0/FChannel-Server/util"
13 | "github.com/FChannel0/FChannel-Server/webfinger"
14 | "github.com/gofiber/fiber/v2"
15 | )
16 |
17 | func NewsGet(ctx *fiber.Ctx) error {
18 | timestamp := ctx.Path()[6:]
19 | ts, err := strconv.Atoi(timestamp)
20 |
21 | if err != nil {
22 | return ctx.Status(404).Render("404", fiber.Map{})
23 | }
24 |
25 | actor, err := activitypub.GetActorFromDB(config.Domain)
26 |
27 | if err != nil {
28 | return util.MakeError(err, "NewsGet")
29 | }
30 |
31 | var data route.PageData
32 | data.PreferredUsername = actor.PreferredUsername
33 | data.Boards = webfinger.Boards
34 | data.Board.Name = ""
35 | data.Key = config.Key
36 | data.Board.Domain = config.Domain
37 | data.Board.ModCred, _ = util.GetPasswordFromSession(ctx)
38 | data.Board.Actor = actor
39 | data.Board.Post.Actor = actor.Id
40 | data.Board.Restricted = actor.Restricted
41 | data.NewsItems = make([]db.NewsItem, 1)
42 |
43 | data.NewsItems[0], err = db.GetNewsItem(ts)
44 | if err != nil {
45 | return util.MakeError(err, "NewsGet")
46 | }
47 |
48 | data.Title = actor.PreferredUsername + ": " + data.NewsItems[0].Title
49 |
50 | data.Meta.Description = data.PreferredUsername + " is a federated image board based on ActivityPub. The current version of the code running on the server is still a work-in-progress product, expect a bumpy ride for the time being. Get the server code here: https://git.fchannel.org."
51 | data.Meta.Url = data.Board.Actor.Id
52 | data.Meta.Title = data.Title
53 |
54 | data.Themes = &config.Themes
55 | data.ThemeCookie = route.GetThemeCookie(ctx)
56 |
57 | return ctx.Render("news", fiber.Map{"page": data}, "layouts/main")
58 | }
59 |
60 | func NewsGetAll(ctx *fiber.Ctx) error {
61 | actor, err := activitypub.GetActorFromDB(config.Domain)
62 | if err != nil {
63 | return util.MakeError(err, "NewsGetAll")
64 | }
65 |
66 | var data route.PageData
67 | data.PreferredUsername = actor.PreferredUsername
68 | data.Title = actor.PreferredUsername + " News"
69 | data.Boards = webfinger.Boards
70 | data.Board.Name = ""
71 | data.Key = config.Key
72 | data.Board.Domain = config.Domain
73 | data.Board.ModCred, _ = util.GetPasswordFromSession(ctx)
74 | data.Board.Actor = actor
75 | data.Board.Post.Actor = actor.Id
76 | data.Board.Restricted = actor.Restricted
77 |
78 | data.NewsItems, err = db.GetNews(0)
79 |
80 | if err != nil {
81 | return util.MakeError(err, "NewsGetAll")
82 | }
83 |
84 | if len(data.NewsItems) == 0 {
85 | return ctx.Redirect("/", http.StatusSeeOther)
86 | }
87 |
88 | data.Meta.Description = data.PreferredUsername + " is a federated image board based on ActivityPub. The current version of the code running on the server is still a work-in-progress product, expect a bumpy ride for the time being. Get the server code here: https://git.fchannel.org."
89 | data.Meta.Url = data.Board.Actor.Id
90 | data.Meta.Title = data.Title
91 |
92 | data.Themes = &config.Themes
93 | data.ThemeCookie = route.GetThemeCookie(ctx)
94 |
95 | return ctx.Render("anews", fiber.Map{"page": data}, "layouts/main")
96 | }
97 |
98 | func NewsPost(ctx *fiber.Ctx) error {
99 | actor, err := activitypub.GetActorFromDB(config.Domain)
100 |
101 | if err != nil {
102 | return util.MakeError(err, "NewPost")
103 | }
104 |
105 | if has := actor.HasValidation(ctx); !has {
106 | return nil
107 | }
108 |
109 | var newsitem db.NewsItem
110 |
111 | newsitem.Title = ctx.FormValue("title")
112 | newsitem.Content = template.HTML(ctx.FormValue("summary"))
113 |
114 | if err := db.WriteNews(newsitem); err != nil {
115 | return util.MakeError(err, "NewPost")
116 | }
117 |
118 | return ctx.Redirect("/", http.StatusSeeOther)
119 | }
120 |
121 | func NewsDelete(ctx *fiber.Ctx) error {
122 | actor, err := activitypub.GetActorFromDB(config.Domain)
123 |
124 | if has := actor.HasValidation(ctx); !has {
125 | return nil
126 | }
127 |
128 | timestamp := ctx.Path()[13+len(config.Key):]
129 |
130 | tsint, err := strconv.Atoi(timestamp)
131 |
132 | if err != nil {
133 | return ctx.Status(404).Render("404", fiber.Map{})
134 | }
135 |
136 | if err := db.DeleteNewsItem(tsint); err != nil {
137 | return util.MakeError(err, "NewsDelete")
138 | }
139 |
140 | return ctx.Redirect("/news/", http.StatusSeeOther)
141 | }
142 |
--------------------------------------------------------------------------------
/route/routes/webfinger.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "encoding/json"
5 | "strings"
6 |
7 | "github.com/FChannel0/FChannel-Server/activitypub"
8 | "github.com/FChannel0/FChannel-Server/config"
9 | "github.com/gofiber/fiber/v2"
10 | )
11 |
12 | func Webfinger(c *fiber.Ctx) error {
13 | acct := c.Query("resource")
14 |
15 | if len(acct) < 1 {
16 | c.Status(fiber.StatusBadRequest)
17 | return c.Send([]byte("resource needs a value"))
18 | }
19 |
20 | acct = strings.Replace(acct, "acct:", "", -1)
21 |
22 | actorDomain := strings.Split(acct, "@")
23 |
24 | if len(actorDomain) < 2 {
25 | c.Status(fiber.StatusBadRequest)
26 | return c.Send([]byte("accepts only subject form of acct:board@instance"))
27 | }
28 |
29 | if actorDomain[0] == "main" {
30 | actorDomain[0] = ""
31 | } else {
32 | actorDomain[0] = "/" + actorDomain[0]
33 | }
34 |
35 | actor := activitypub.Actor{Id: config.TP + "" + actorDomain[1] + "" + actorDomain[0]}
36 | if res, _ := actor.IsLocal(); !res {
37 | c.Status(fiber.StatusBadRequest)
38 | return c.Send([]byte("actor not local"))
39 | }
40 |
41 | var finger activitypub.Webfinger
42 | var link activitypub.WebfingerLink
43 |
44 | finger.Subject = "acct:" + actorDomain[0] + "@" + actorDomain[1]
45 | link.Rel = "self"
46 | link.Type = "application/activity+json"
47 | link.Href = config.TP + "" + actorDomain[1] + "" + actorDomain[0]
48 |
49 | finger.Links = append(finger.Links, link)
50 |
51 | enc, _ := json.Marshal(finger)
52 |
53 | c.Set("Content-Type", config.ActivityStreams)
54 | return c.Send(enc)
55 | }
56 |
--------------------------------------------------------------------------------
/route/structs.go:
--------------------------------------------------------------------------------
1 | package route
2 |
3 | import (
4 | "github.com/FChannel0/FChannel-Server/activitypub"
5 | "github.com/FChannel0/FChannel-Server/db"
6 | "github.com/FChannel0/FChannel-Server/util"
7 | "github.com/FChannel0/FChannel-Server/webfinger"
8 | )
9 |
10 | type PageData struct {
11 | Title string
12 | PreferredUsername string
13 | Board webfinger.Board
14 | Pages []int
15 | CurrentPage int
16 | TotalPage int
17 | Boards []webfinger.Board
18 | Posts []activitypub.ObjectBase
19 | Key string
20 | PostId string
21 | Instance activitypub.Actor
22 | ReturnTo string
23 | NewsItems []db.NewsItem
24 | BoardRemainer []int
25 | Meta Meta
26 | PostType string
27 |
28 | Themes *[]string
29 | ThemeCookie string
30 | }
31 |
32 | type AdminPage struct {
33 | Title string
34 | Board webfinger.Board
35 | Key string
36 | Actor string
37 | Boards []webfinger.Board
38 | Following []string
39 | Followers []string
40 | Domain string
41 | IsLocal bool
42 | PostBlacklist []util.PostBlacklist
43 | AutoSubscribe bool
44 | RecentPosts []activitypub.ObjectBase
45 | Instance activitypub.Actor
46 | Meta Meta
47 |
48 | Themes *[]string
49 | ThemeCookie string
50 | }
51 |
52 | type Meta struct {
53 | Title string
54 | Description string
55 | Url string
56 | Preview string
57 | }
58 |
--------------------------------------------------------------------------------
/route/util.go:
--------------------------------------------------------------------------------
1 | package route
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "html/template"
7 | "regexp"
8 | "strings"
9 | "time"
10 |
11 | "github.com/FChannel0/FChannel-Server/activitypub"
12 | "github.com/FChannel0/FChannel-Server/config"
13 | "github.com/FChannel0/FChannel-Server/db"
14 | "github.com/FChannel0/FChannel-Server/post"
15 | "github.com/FChannel0/FChannel-Server/util"
16 | "github.com/FChannel0/FChannel-Server/webfinger"
17 | "github.com/gofiber/fiber/v2"
18 | "github.com/gofiber/template/html"
19 | )
20 |
21 | func GetThemeCookie(c *fiber.Ctx) string {
22 | cookie := c.Cookies("theme")
23 | if cookie != "" {
24 | cookies := strings.SplitN(cookie, "=", 2)
25 | return cookies[0]
26 | }
27 |
28 | return "default"
29 | }
30 |
31 | func WantToServeCatalog(actorName string) (activitypub.Collection, bool, error) {
32 | var collection activitypub.Collection
33 | serve := false
34 |
35 | actor, err := activitypub.GetActorByNameFromDB(actorName)
36 | if err != nil {
37 | return collection, false, util.MakeError(err, "WantToServeCatalog")
38 | }
39 |
40 | if actor.Id != "" {
41 | collection, err = actor.GetCatalogCollection()
42 | if err != nil {
43 | return collection, false, util.MakeError(err, "WantToServeCatalog")
44 | }
45 |
46 | collection.Actor = actor
47 | return collection, true, nil
48 | }
49 |
50 | return collection, serve, nil
51 | }
52 |
53 | func WantToServeArchive(actorName string) (activitypub.Collection, bool, error) {
54 | var collection activitypub.Collection
55 | serve := false
56 |
57 | actor, err := activitypub.GetActorByNameFromDB(actorName)
58 | if err != nil {
59 | return collection, false, util.MakeError(err, "WantToServeArchive")
60 | }
61 |
62 | if actor.Id != "" {
63 | collection, err = actor.GetCollectionType("Archive")
64 | if err != nil {
65 | return collection, false, util.MakeError(err, "WantToServeArchive")
66 | }
67 |
68 | collection.Actor = actor
69 | return collection, true, nil
70 | }
71 |
72 | return collection, serve, nil
73 | }
74 |
75 | func GetActorPost(ctx *fiber.Ctx, path string) error {
76 | obj := activitypub.ObjectBase{Id: config.Domain + "" + path}
77 | collection, err := obj.GetCollectionFromPath()
78 |
79 | if err != nil {
80 | return util.MakeError(err, "GetActorPost")
81 | }
82 |
83 | if len(collection.OrderedItems) > 0 {
84 | enc, err := json.MarshalIndent(collection, "", "\t")
85 | if err != nil {
86 | return util.MakeError(err, "GetActorPost")
87 | }
88 |
89 | ctx.Response().Header.Set("Content-Type", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")
90 | _, err = ctx.Write(enc)
91 | return util.MakeError(err, "GetActorPost")
92 | }
93 |
94 | return nil
95 | }
96 |
97 | func ParseOutboxRequest(ctx *fiber.Ctx, actor activitypub.Actor) error {
98 | contentType := util.GetContentType(ctx.Get("content-type"))
99 |
100 | if contentType == "multipart/form-data" || contentType == "application/x-www-form-urlencoded" {
101 | hasCaptcha, err := util.BoardHasAuthType(actor.Name, "captcha")
102 | if err != nil {
103 | return util.MakeError(err, "ParseOutboxRequest")
104 | }
105 |
106 | valid, err := post.CheckCaptcha(ctx.FormValue("captcha"))
107 | if err == nil && hasCaptcha && valid {
108 | header, _ := ctx.FormFile("file")
109 | if header != nil {
110 | f, _ := header.Open()
111 | defer f.Close()
112 | if header.Size > (7 << 20) {
113 | ctx.Response().Header.SetStatusCode(403)
114 | _, err := ctx.Write([]byte("7MB max file size"))
115 | return util.MakeError(err, "ParseOutboxRequest")
116 | } else if isBanned, err := post.IsMediaBanned(f); err == nil && isBanned {
117 | config.Log.Println("media banned")
118 | ctx.Response().Header.SetStatusCode(403)
119 | _, err := ctx.Write([]byte(""))
120 | return util.MakeError(err, "ParseOutboxRequest")
121 | } else if err != nil {
122 | return util.MakeError(err, "ParseOutboxRequest")
123 | }
124 |
125 | contentType, _ := util.GetFileContentType(f)
126 |
127 | if !post.SupportedMIMEType(contentType) {
128 | ctx.Response().Header.SetStatusCode(403)
129 | _, err := ctx.Write([]byte("file type not supported"))
130 | return util.MakeError(err, "ParseOutboxRequest")
131 | }
132 | }
133 |
134 | var nObj = activitypub.CreateObject("Note")
135 | nObj, err := post.ObjectFromForm(ctx, nObj)
136 | if err != nil {
137 | return util.MakeError(err, "ParseOutboxRequest")
138 | }
139 |
140 | nObj.Actor = config.Domain + "/" + actor.Name
141 |
142 | if locked, _ := nObj.InReplyTo[0].IsLocked(); locked {
143 | ctx.Response().Header.SetStatusCode(403)
144 | _, err := ctx.Write([]byte("thread is locked"))
145 | return util.MakeError(err, "ParseOutboxRequest")
146 | }
147 |
148 | nObj, err = nObj.Write()
149 | if err != nil {
150 | return util.MakeError(err, "ParseOutboxRequest")
151 | }
152 |
153 | if len(nObj.To) == 0 {
154 | if err := actor.ArchivePosts(); err != nil {
155 | return util.MakeError(err, "ParseOutboxRequest")
156 | }
157 | }
158 |
159 | go func(nObj activitypub.ObjectBase) {
160 | activity, err := nObj.CreateActivity("Create")
161 | if err != nil {
162 | config.Log.Printf("ParseOutboxRequest Create Activity: %s", err)
163 | }
164 |
165 | activity, err = activity.AddFollowersTo()
166 | if err != nil {
167 | config.Log.Printf("ParseOutboxRequest Add FollowersTo: %s", err)
168 | }
169 |
170 | if err := activity.MakeRequestInbox(); err != nil {
171 | config.Log.Printf("ParseOutboxRequest MakeRequestInbox: %s", err)
172 | }
173 | }(nObj)
174 |
175 | go func(obj activitypub.ObjectBase) {
176 | err := obj.SendEmailNotify()
177 |
178 | if err != nil {
179 | config.Log.Println(err)
180 | }
181 | }(nObj)
182 |
183 | var id string
184 | op := len(nObj.InReplyTo) - 1
185 | if op >= 0 {
186 | if nObj.InReplyTo[op].Id == "" {
187 | id = nObj.Id
188 | } else {
189 | id = nObj.InReplyTo[0].Id + "|" + nObj.Id
190 | }
191 | }
192 |
193 | ctx.Response().Header.Set("Status", "200")
194 | _, err = ctx.Write([]byte(id))
195 | return util.MakeError(err, "ParseOutboxRequest")
196 | }
197 |
198 | ctx.Response().Header.Set("Status", "403")
199 | _, err = ctx.Write([]byte("captcha could not auth"))
200 | return util.MakeError(err, "")
201 | } else { // json request
202 | activity, err := activitypub.GetActivityFromJson(ctx)
203 | if err != nil {
204 | return util.MakeError(err, "ParseOutboxRequest")
205 | }
206 |
207 | if res, _ := activity.IsLocal(); res {
208 | if res := activity.Actor.VerifyHeaderSignature(ctx); err == nil && !res {
209 | ctx.Response().Header.Set("Status", "403")
210 | _, err = ctx.Write([]byte(""))
211 | return util.MakeError(err, "ParseOutboxRequest")
212 | }
213 |
214 | switch activity.Type {
215 | case "Create":
216 | ctx.Response().Header.Set("Status", "403")
217 | _, err = ctx.Write([]byte(""))
218 | break
219 |
220 | case "Follow":
221 | validActor := (activity.Object.Actor != "")
222 | validLocalActor := (activity.Actor.Id == actor.Id)
223 |
224 | var rActivity activitypub.Activity
225 |
226 | if validActor && validLocalActor {
227 | rActivity = activity.AcceptFollow()
228 | rActivity, err = rActivity.SetActorFollowing()
229 |
230 | if err != nil {
231 | return util.MakeError(err, "ParseOutboxRequest")
232 | }
233 |
234 | if err := activity.MakeRequestInbox(); err != nil {
235 | return util.MakeError(err, "ParseOutboxRequest")
236 | }
237 | }
238 |
239 | actor, _ := activitypub.GetActorFromDB(config.Domain)
240 | webfinger.FollowingBoards, err = actor.GetFollowing()
241 |
242 | if err != nil {
243 | return util.MakeError(err, "ParseOutboxRequest")
244 | }
245 |
246 | webfinger.Boards, err = webfinger.GetBoardCollection()
247 |
248 | if err != nil {
249 | return util.MakeError(err, "ParseOutboxRequest")
250 | }
251 | break
252 |
253 | case "Delete":
254 | config.Log.Println("This is a delete")
255 | ctx.Response().Header.Set("Status", "403")
256 | _, err = ctx.Write([]byte("could not process activity"))
257 | break
258 |
259 | case "Note":
260 | ctx.Response().Header.Set("Satus", "403")
261 | _, err = ctx.Write([]byte("could not process activity"))
262 | break
263 |
264 | case "New":
265 | name := activity.Object.Alias
266 | prefname := activity.Object.Name
267 | summary := activity.Object.Summary
268 | restricted := activity.Object.Sensitive
269 |
270 | actor, err := db.CreateNewBoard(*activitypub.CreateNewActor(name, prefname, summary, config.AuthReq, restricted))
271 | if err != nil {
272 | return util.MakeError(err, "ParseOutboxRequest")
273 | }
274 |
275 | if actor.Id != "" {
276 | var board []activitypub.ObjectBase
277 | var item activitypub.ObjectBase
278 | var removed bool = false
279 |
280 | item.Id = actor.Id
281 | for _, e := range webfinger.FollowingBoards {
282 | if e.Id != item.Id {
283 | board = append(board, e)
284 | } else {
285 | removed = true
286 | }
287 | }
288 |
289 | if !removed {
290 | board = append(board, item)
291 | }
292 |
293 | webfinger.FollowingBoards = board
294 | webfinger.Boards, err = webfinger.GetBoardCollection()
295 | return util.MakeError(err, "ParseOutboxRequest")
296 | }
297 |
298 | ctx.Response().Header.Set("Status", "403")
299 | _, err = ctx.Write([]byte(""))
300 | break
301 |
302 | default:
303 | ctx.Response().Header.Set("status", "403")
304 | _, err = ctx.Write([]byte("could not process activity"))
305 | }
306 | } else if err != nil {
307 | return util.MakeError(err, "ParseOutboxRequest")
308 | } else {
309 | config.Log.Println("is NOT activity")
310 | ctx.Response().Header.Set("Status", "403")
311 | _, err = ctx.Write([]byte("could not process activity"))
312 | return util.MakeError(err, "ParseOutboxRequest")
313 | }
314 | }
315 |
316 | return nil
317 | }
318 |
319 | func TemplateFunctions(engine *html.Engine) {
320 | engine.AddFunc("mod", func(i, j int) bool {
321 | return i%j == 0
322 | })
323 |
324 | engine.AddFunc("sub", func(i, j int) int {
325 | return i - j
326 | })
327 |
328 | engine.AddFunc("add", func(i, j int) int {
329 | return i + j
330 | })
331 |
332 | engine.AddFunc("unixtoreadable", func(u int) string {
333 | return time.Unix(int64(u), 0).Format("Jan 02, 2006")
334 | })
335 |
336 | engine.AddFunc("timeToReadableLong", func(t time.Time) string {
337 | return t.Format("01/02/06(Mon)15:04:05")
338 | })
339 |
340 | engine.AddFunc("timeToUnix", func(t time.Time) string {
341 | return fmt.Sprint(t.Unix())
342 | })
343 |
344 | engine.AddFunc("proxy", util.MediaProxy)
345 |
346 | // previously short
347 | engine.AddFunc("shortURL", util.ShortURL)
348 |
349 | engine.AddFunc("parseAttachment", post.ParseAttachment)
350 |
351 | engine.AddFunc("parseContent", post.ParseContent)
352 |
353 | engine.AddFunc("shortImg", util.ShortImg)
354 |
355 | engine.AddFunc("convertSize", util.ConvertSize)
356 |
357 | engine.AddFunc("isOnion", util.IsOnion)
358 |
359 | engine.AddFunc("parseReplyLink", func(actorId string, op string, id string, content string) template.HTML {
360 | actor, _ := activitypub.FingerActor(actorId)
361 | title := strings.ReplaceAll(post.ParseLinkTitle(actor.Id+"/", op, content), `/\<`, ">")
362 | link := ">>" + util.ShortURL(actor.Outbox, id) + ""
363 | return template.HTML(link)
364 | })
365 |
366 | engine.AddFunc("shortExcerpt", func(post activitypub.ObjectBase) template.HTML {
367 | var returnString string
368 |
369 | if post.Name != "" {
370 | returnString = post.Name + "| " + post.Content
371 | } else {
372 | returnString = post.Content
373 | }
374 |
375 | re := regexp.MustCompile(`(^(.|\r\n|\n){100})`)
376 |
377 | match := re.FindStringSubmatch(returnString)
378 |
379 | if len(match) > 0 {
380 | returnString = match[0] + "..."
381 | }
382 |
383 | returnString = strings.ReplaceAll(returnString, "<", "<")
384 | returnString = strings.ReplaceAll(returnString, ">", ">")
385 |
386 | re = regexp.MustCompile(`(^.+\|)`)
387 |
388 | match = re.FindStringSubmatch(returnString)
389 |
390 | if len(match) > 0 {
391 | returnString = strings.Replace(returnString, match[0], ""+match[0]+"", 1)
392 | returnString = strings.Replace(returnString, "|", ":", 1)
393 | }
394 |
395 | return template.HTML(returnString)
396 | })
397 |
398 | engine.AddFunc("parseLinkTitle", func(board string, op string, content string) string {
399 | nContent := post.ParseLinkTitle(board, op, content)
400 | nContent = strings.ReplaceAll(nContent, `/\<`, ">")
401 |
402 | return nContent
403 | })
404 |
405 | engine.AddFunc("parseLink", func(board activitypub.Actor, link string) string {
406 | var obj = activitypub.ObjectBase{
407 | Id: link,
408 | }
409 |
410 | var OP string
411 | if OP, _ = obj.GetOP(); OP == obj.Id {
412 | return board.Name + "/" + util.ShortURL(board.Outbox, obj.Id)
413 | }
414 |
415 | return board.Name + "/" + util.ShortURL(board.Outbox, OP) + "#" + util.ShortURL(board.Outbox, link)
416 | })
417 |
418 | engine.AddFunc("showArchive", func(actor activitypub.Actor) bool {
419 | col, err := actor.GetCollectionTypeLimit("Archive", 1)
420 |
421 | if err != nil || len(col.OrderedItems) == 0 {
422 | return false
423 | }
424 |
425 | return true
426 | })
427 | }
428 |
--------------------------------------------------------------------------------
/util/blacklist.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "regexp"
5 |
6 | "github.com/FChannel0/FChannel-Server/config"
7 | )
8 |
9 | type PostBlacklist struct {
10 | Id int
11 | Regex string
12 | }
13 |
14 | func DeleteRegexBlacklist(id int) error {
15 | query := `delete from postblacklist where id=$1`
16 | _, err := config.DB.Exec(query, id)
17 |
18 | return MakeError(err, "DeleteRegexBlacklist")
19 | }
20 |
21 | func GetRegexBlacklist() ([]PostBlacklist, error) {
22 | var list []PostBlacklist
23 |
24 | query := `select id, regex from postblacklist`
25 | rows, err := config.DB.Query(query)
26 |
27 | if err != nil {
28 | return list, MakeError(err, "GetRegexBlacklist")
29 | }
30 |
31 | defer rows.Close()
32 | for rows.Next() {
33 | var temp PostBlacklist
34 |
35 | rows.Scan(&temp.Id, &temp.Regex)
36 | list = append(list, temp)
37 | }
38 |
39 | return list, nil
40 | }
41 |
42 | func IsPostBlacklist(comment string) (bool, error) {
43 | postblacklist, err := GetRegexBlacklist()
44 |
45 | if err != nil {
46 | return false, MakeError(err, "IsPostBlacklist")
47 | }
48 |
49 | for _, e := range postblacklist {
50 | re := regexp.MustCompile(e.Regex)
51 |
52 | if re.MatchString(comment) {
53 | return true, nil
54 | }
55 | }
56 |
57 | return false, nil
58 | }
59 |
60 | func WriteRegexBlacklist(regex string) error {
61 | var re string
62 |
63 | query := `select from postblacklist where regex=$1`
64 | if err := config.DB.QueryRow(query, regex).Scan(&re); err != nil {
65 | query = `insert into postblacklist (regex) values ($1)`
66 | _, err := config.DB.Exec(query, regex)
67 | return MakeError(err, "WriteRegexBlacklist")
68 | }
69 |
70 | return nil
71 | }
72 |
--------------------------------------------------------------------------------
/util/key.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "crypto/sha512"
5 | "encoding/hex"
6 | "errors"
7 | "math/rand"
8 | "os"
9 | "strings"
10 |
11 | "github.com/FChannel0/FChannel-Server/config"
12 | "github.com/gofiber/fiber/v2/middleware/encryptcookie"
13 | )
14 |
15 | const domain = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
16 |
17 | func CreateKey(len int) (string, error) {
18 | // TODO: provided that CreateTripCode still uses sha512, the max len can be 128 at most.
19 | if len > 128 {
20 | return "", MakeError(errors.New("len is greater than 128"), "CreateKey")
21 | }
22 |
23 | str := CreateTripCode(RandomID(len))
24 | return str[:len], nil
25 | }
26 |
27 | func CreateTripCode(input string) string {
28 | out := sha512.Sum512([]byte(input))
29 |
30 | return hex.EncodeToString(out[:])
31 | }
32 |
33 | func GetCookieKey() (string, error) {
34 | if config.CookieKey == "" {
35 | var file *os.File
36 | var err error
37 |
38 | if file, err = os.OpenFile("config/config-init", os.O_APPEND|os.O_WRONLY, 0644); err != nil {
39 | return "", MakeError(err, "GetCookieKey")
40 | }
41 |
42 | defer file.Close()
43 |
44 | config.CookieKey = encryptcookie.GenerateKey()
45 | file.WriteString("\ncookiekey:" + config.CookieKey)
46 | }
47 |
48 | return config.CookieKey, nil
49 | }
50 |
51 | func RandomID(size int) string {
52 | rng := size
53 | newID := strings.Builder{}
54 |
55 | for i := 0; i < rng; i++ {
56 | newID.WriteByte(domain[rand.Intn(len(domain))])
57 | }
58 |
59 | return newID.String()
60 | }
61 |
--------------------------------------------------------------------------------
/util/proxy.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "net/http"
5 | "net/url"
6 | "regexp"
7 | "time"
8 |
9 | "github.com/FChannel0/FChannel-Server/config"
10 | )
11 |
12 | func GetPathProxyType(path string) string {
13 | if config.TorProxy != "" {
14 | re := regexp.MustCompile(`(http://|http://)?(www.)?\w+\.onion`)
15 | onion := re.MatchString(path)
16 |
17 | if onion {
18 | return "tor"
19 | }
20 | }
21 |
22 | return "clearnet"
23 | }
24 |
25 | func MediaProxy(url string) string {
26 | re := regexp.MustCompile("(.+)?" + config.Domain + "(.+)?")
27 | if re.MatchString(url) {
28 | return url
29 | }
30 |
31 | re = regexp.MustCompile("(.+)?\\.onion(.+)?")
32 | if re.MatchString(url) {
33 | return url
34 | }
35 |
36 | config.MediaHashs[HashMedia(url)] = url
37 |
38 | return "/api/media?hash=" + HashMedia(url)
39 | }
40 |
41 | func RouteProxy(req *http.Request) (*http.Response, error) {
42 | var proxyType = GetPathProxyType(req.URL.Host)
43 |
44 | req.Header.Set("User-Agent", "FChannel/"+config.InstanceName)
45 |
46 | if proxyType == "tor" {
47 | proxyUrl, err := url.Parse("socks5://" + config.TorProxy)
48 |
49 | if err != nil {
50 | return nil, MakeError(err, "RouteProxy")
51 | }
52 |
53 | proxyTransport := &http.Transport{Proxy: http.ProxyURL(proxyUrl)}
54 | client := &http.Client{Transport: proxyTransport, Timeout: time.Second * 15}
55 |
56 | return client.Do(req)
57 | }
58 |
59 | return http.DefaultClient.Do(req)
60 | }
61 |
--------------------------------------------------------------------------------
/util/util.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "crypto/sha256"
5 | "database/sql"
6 | "encoding/hex"
7 | "errors"
8 | "fmt"
9 | "io/ioutil"
10 | "mime/multipart"
11 | "net/http"
12 | "os"
13 | "path"
14 | "regexp"
15 | "runtime"
16 | "strings"
17 |
18 | "github.com/FChannel0/FChannel-Server/config"
19 | )
20 |
21 | func IsOnion(url string) bool {
22 | re := regexp.MustCompile(`\.onion`)
23 | if re.MatchString(url) {
24 | return true
25 | }
26 |
27 | return false
28 | }
29 |
30 | func StripTransferProtocol(value string) string {
31 | re := regexp.MustCompile("(http://|https://)?(www.)?")
32 | value = re.ReplaceAllString(value, "")
33 |
34 | return value
35 | }
36 |
37 | func ShortURL(actorName string, url string) string {
38 | var reply string
39 |
40 | re := regexp.MustCompile(`.+\/`)
41 | actor := re.FindString(actorName)
42 | urlParts := strings.Split(url, "|")
43 | op := urlParts[0]
44 |
45 | if len(urlParts) > 1 {
46 | reply = urlParts[1]
47 | }
48 |
49 | re = regexp.MustCompile(`\w+$`)
50 | temp := re.ReplaceAllString(op, "")
51 |
52 | if temp == actor {
53 | id := LocalShort(op)
54 |
55 | re := regexp.MustCompile(`.+\/`)
56 | replyCheck := re.FindString(reply)
57 |
58 | if reply != "" && replyCheck == actor {
59 | id = id + "#" + LocalShort(reply)
60 | } else if reply != "" {
61 | id = id + "#" + RemoteShort(reply)
62 | }
63 |
64 | return id
65 | } else {
66 | id := RemoteShort(op)
67 |
68 | re := regexp.MustCompile(`.+\/`)
69 | replyCheck := re.FindString(reply)
70 |
71 | if reply != "" && replyCheck == actor {
72 | id = id + "#" + LocalShort(reply)
73 | } else if reply != "" {
74 | id = id + "#" + RemoteShort(reply)
75 | }
76 |
77 | return id
78 | }
79 | }
80 |
81 | func LocalShort(url string) string {
82 | re := regexp.MustCompile(`\w+$`)
83 | return re.FindString(StripTransferProtocol(url))
84 | }
85 |
86 | func RemoteShort(url string) string {
87 | re := regexp.MustCompile(`\w+$`)
88 | id := re.FindString(StripTransferProtocol(url))
89 | re = regexp.MustCompile(`.+/.+/`)
90 | actorurl := re.FindString(StripTransferProtocol(url))
91 | re = regexp.MustCompile(`/.+/`)
92 | actorname := re.FindString(actorurl)
93 | actorname = strings.Replace(actorname, "/", "", -1)
94 |
95 | return "f" + actorname + "-" + id
96 | }
97 |
98 | func ShortImg(url string) string {
99 | nURL := url
100 | re := regexp.MustCompile(`(\.\w+$)`)
101 | fileName := re.ReplaceAllString(url, "")
102 |
103 | if len(fileName) > 26 {
104 | re := regexp.MustCompile(`(^.{26})`)
105 |
106 | match := re.FindStringSubmatch(fileName)
107 |
108 | if len(match) > 0 {
109 | nURL = match[0]
110 | }
111 |
112 | re = regexp.MustCompile(`(\..+$)`)
113 |
114 | match = re.FindStringSubmatch(url)
115 |
116 | if len(match) > 0 {
117 | nURL = nURL + "(...)" + match[0]
118 | }
119 | }
120 |
121 | return nURL
122 | }
123 |
124 | func ConvertSize(size int64) string {
125 | var rValue string
126 |
127 | convert := float32(size) / 1024.0
128 |
129 | if convert > 1024 {
130 | convert = convert / 1024.0
131 | rValue = fmt.Sprintf("%.2f MB", convert)
132 | } else {
133 | rValue = fmt.Sprintf("%.2f KB", convert)
134 | }
135 |
136 | return rValue
137 | }
138 |
139 | // IsInStringArray looks for a string in a string array and returns true if it is found.
140 | func IsInStringArray(haystack []string, needle string) bool {
141 | for _, e := range haystack {
142 | if e == needle {
143 | return true
144 | }
145 | }
146 | return false
147 | }
148 |
149 | // GetUniqueFilename will look for an available random filename in the /public/ directory.
150 | func GetUniqueFilename(ext string) string {
151 | id := RandomID(8)
152 | file := "/public/" + id + "." + ext
153 |
154 | for true {
155 | if _, err := os.Stat("." + file); err == nil {
156 | id = RandomID(8)
157 | file = "/public/" + id + "." + ext
158 | } else {
159 | return "/public/" + id + "." + ext
160 | }
161 | }
162 |
163 | return ""
164 | }
165 |
166 | func HashMedia(media string) string {
167 | h := sha256.New()
168 | h.Write([]byte(media))
169 | return hex.EncodeToString(h.Sum(nil))
170 | }
171 |
172 | func HashBytes(media []byte) string {
173 | h := sha256.New()
174 | h.Write(media)
175 | return hex.EncodeToString(h.Sum(nil))
176 | }
177 |
178 | func EscapeString(text string) string {
179 | // TODO: not enough
180 | text = strings.Replace(text, "<", "<", -1)
181 | return text
182 | }
183 |
184 | func CreateUniqueID(actor string) (string, error) {
185 | var newID string
186 |
187 | for true {
188 | newID = RandomID(8)
189 | query := "select id from activitystream where id=$1"
190 | args := fmt.Sprintf("%s/%s/%s", config.Domain, actor, newID)
191 |
192 | if err := config.DB.QueryRow(query, args); err != nil {
193 | break
194 | }
195 | }
196 |
197 | return newID, nil
198 | }
199 |
200 | func GetFileContentType(out multipart.File) (string, error) {
201 | buffer := make([]byte, 512)
202 | _, err := out.Read(buffer)
203 |
204 | if err != nil {
205 | return "", MakeError(err, "GetFileContentType")
206 | }
207 |
208 | out.Seek(0, 0)
209 | contentType := http.DetectContentType(buffer)
210 |
211 | return contentType, nil
212 | }
213 |
214 | func GetContentType(location string) string {
215 | elements := strings.Split(location, ";")
216 |
217 | if len(elements) > 0 {
218 | return elements[0]
219 | }
220 |
221 | return location
222 | }
223 |
224 | func CreatedNeededDirectories() error {
225 | if _, err := os.Stat("./public"); os.IsNotExist(err) {
226 | if err = os.Mkdir("./public", 0755); err != nil {
227 | return MakeError(err, "CreatedNeededDirectories")
228 | }
229 | }
230 |
231 | if _, err := os.Stat("./pem/board"); os.IsNotExist(err) {
232 | if err = os.MkdirAll("./pem/board", 0700); err != nil {
233 | return MakeError(err, "CreatedNeededDirectories")
234 | }
235 | }
236 |
237 | return nil
238 | }
239 |
240 | func LoadThemes() error {
241 | themes, err := ioutil.ReadDir("./views/css/themes")
242 |
243 | if err != nil {
244 | MakeError(err, "LoadThemes")
245 | }
246 |
247 | for _, f := range themes {
248 | if e := path.Ext(f.Name()); e == ".css" {
249 | config.Themes = append(config.Themes, strings.TrimSuffix(f.Name(), e))
250 | }
251 | }
252 |
253 | return nil
254 | }
255 |
256 | func GetBoardAuth(board string) ([]string, error) {
257 | var auth []string
258 | var rows *sql.Rows
259 | var err error
260 |
261 | query := `select type from actorauth where board=$1`
262 | if rows, err = config.DB.Query(query, board); err != nil {
263 | return auth, MakeError(err, "GetBoardAuth")
264 | }
265 |
266 | defer rows.Close()
267 | for rows.Next() {
268 | var _type string
269 | if err := rows.Scan(&_type); err != nil {
270 | return auth, MakeError(err, "GetBoardAuth")
271 | }
272 |
273 | auth = append(auth, _type)
274 | }
275 |
276 | return auth, nil
277 | }
278 |
279 | func MakeError(err error, msg string) error {
280 | if err != nil {
281 | _, _, line, _ := runtime.Caller(1)
282 | s := fmt.Sprintf("%s:%d : %s", msg, line, err.Error())
283 | return errors.New(s)
284 | }
285 |
286 | return nil
287 | }
288 |
--------------------------------------------------------------------------------
/util/verification.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "fmt"
5 | "math/rand"
6 | "net/smtp"
7 | "os"
8 | "os/exec"
9 | "strings"
10 | "time"
11 |
12 | "github.com/FChannel0/FChannel-Server/config"
13 | "github.com/gofiber/fiber/v2"
14 | _ "github.com/lib/pq"
15 | )
16 |
17 | type Verify struct {
18 | Type string
19 | Identifier string
20 | Code string
21 | Created string
22 | Board string
23 | Label string
24 | }
25 |
26 | type VerifyCooldown struct {
27 | Identifier string
28 | Code string
29 | Time int
30 | }
31 |
32 | type Signature struct {
33 | KeyId string
34 | Headers []string
35 | Signature string
36 | Algorithm string
37 | }
38 |
39 | func (verify Verify) Create() error {
40 | query := `insert into verification (type, identifier, code, created) values ($1, $2, $3, $4)`
41 | _, err := config.DB.Exec(query, verify.Type, verify.Identifier, verify.Code, time.Now().UTC().Format(time.RFC3339))
42 |
43 | return MakeError(err, "Create")
44 | }
45 |
46 | func (verify Verify) CreateBoardAccess() error {
47 | if hasAccess, _ := verify.HasBoardAccess(); !hasAccess {
48 | if verify.Label == "" {
49 | verify.Label = "Anon"
50 | }
51 | query := `insert into boardaccess (identifier, board, label) values($1, $2, $3)`
52 | _, err := config.DB.Exec(query, verify.Identifier, verify.Board, verify.Label)
53 |
54 | return MakeError(err, "CreateBoardAccess")
55 | }
56 |
57 | return nil
58 | }
59 |
60 | func (verify Verify) CreateBoardMod() error {
61 | var pass string
62 | var err error
63 |
64 | if pass, err = CreateKey(50); err != nil {
65 | return MakeError(err, "CreateBoardMod")
66 | }
67 |
68 | var code string
69 |
70 | query := `select code from verification where identifier=$1 and type=$2 and code not in (select verificationcode from crossverification)`
71 | if err := config.DB.QueryRow(query, verify.Board, verify.Type).Scan(&code); err != nil {
72 | return MakeError(err, "CreateBoardMod")
73 | }
74 |
75 | var ident string
76 |
77 | query = `select identifier from boardaccess where identifier=$1 and board=$2 and code not in (select code from crossverification)`
78 | if err := config.DB.QueryRow(query, verify.Identifier, verify.Board).Scan(&ident); err != nil {
79 | query := `insert into crossverification (verificationcode, code) values ($1, $2)`
80 | if _, err := config.DB.Exec(query, code, pass); err != nil {
81 | return MakeError(err, "CreateBoardMod")
82 | }
83 |
84 | if verify.Label == "" {
85 | verify.Label = "Anon"
86 | }
87 |
88 | query = `insert into boardaccess (identifier, code, board, type, label) values ($1, $2, $3, $4, $5)`
89 | if _, err = config.DB.Exec(query, verify.Identifier, pass, verify.Board, verify.Type, verify.Label); err != nil {
90 | return MakeError(err, "CreateBoardMod")
91 | }
92 | }
93 |
94 | return nil
95 | }
96 |
97 | func (verify Verify) DeleteBoardMod() error {
98 | var code string
99 |
100 | query := `select code from boardaccess where identifier=$1 and board=$1`
101 | if err := config.DB.QueryRow(query, verify.Identifier, verify.Board).Scan(&code); err != nil {
102 | return nil
103 | }
104 |
105 | query = `delete from crossverification where code=$1`
106 | if _, err := config.DB.Exec(query, code); err != nil {
107 | return MakeError(err, "DeleteBoardMod")
108 | }
109 |
110 | query = `delete from boardaccess where identifier=$1 and board=$2`
111 | if _, err := config.DB.Exec(query, verify.Identifier, verify.Board); err != nil {
112 | return MakeError(err, "DeleteBoardMod")
113 | }
114 |
115 | return nil
116 | }
117 |
118 | func (verify Verify) GetBoardMod() (Verify, error) {
119 | var nVerify Verify
120 |
121 | query := `select code, board, type, identifier from boardaccess where identifier=$1`
122 | if err := config.DB.QueryRow(query, verify.Identifier).Scan(&nVerify.Code, &nVerify.Board, &nVerify.Type, &nVerify.Identifier); err != nil {
123 | return nVerify, MakeError(err, "GetBoardMod")
124 | }
125 |
126 | return nVerify, nil
127 | }
128 |
129 | func (verify Verify) GetCode() (Verify, error) {
130 | var nVerify Verify
131 |
132 | query := `select type, identifier, code, board from boardaccess where identifier=$1 and board=$2`
133 | if err := config.DB.QueryRow(query, verify.Identifier, verify.Board).Scan(&nVerify.Type, &nVerify.Identifier, &nVerify.Code, &nVerify.Board); err != nil {
134 | return verify, nil
135 | }
136 |
137 | return nVerify, nil
138 | }
139 |
140 | func (verify Verify) HasBoardAccess() (bool, string) {
141 | var _type string
142 |
143 | query := `select type from boardaccess where identifier=$1 and board=$2`
144 | if err := config.DB.QueryRow(query, verify.Identifier, verify.Board).Scan(&_type); err != nil {
145 | return false, ""
146 | }
147 |
148 | return true, _type
149 | }
150 |
151 | func (verify Verify) SendVerification() error {
152 | config.Log.Println("sending email")
153 |
154 | from := config.SiteEmail
155 | pass := config.SiteEmailPassword
156 | to := verify.Identifier
157 | body := fmt.Sprintf("You can use either\r\nEmail: %s \r\n Verfication Code: %s\r\n for the board %s", verify.Identifier, verify.Code, verify.Board)
158 |
159 | msg := "From: " + from + "\n" +
160 | "To: " + to + "\n" +
161 | "Subject: Image Board Verification\n\n" +
162 | body
163 |
164 | err := smtp.SendMail(config.SiteEmailServer+":"+config.SiteEmailPort,
165 | smtp.PlainAuth("", from, pass, config.SiteEmailServer),
166 | from, []string{to}, []byte(msg))
167 |
168 | return MakeError(err, "SendVerification")
169 | }
170 |
171 | func (verify Verify) VerifyCooldownAdd() error {
172 | query := `insert into verficationcooldown (identifier, code) values ($1, $2)`
173 | _, err := config.DB.Exec(query, verify.Identifier, verify.Code)
174 |
175 | return MakeError(err, "VerifyCooldownAdd")
176 | }
177 |
178 | func BoardHasAuthType(board string, auth string) (bool, error) {
179 | authTypes, err := GetBoardAuth(board)
180 |
181 | if err != nil {
182 | return false, MakeError(err, "BoardHasAuthType")
183 | }
184 |
185 | for _, e := range authTypes {
186 | if e == auth {
187 | return true, nil
188 | }
189 | }
190 |
191 | return false, nil
192 | }
193 |
194 | func Captcha() string {
195 | rand.Seed(time.Now().UTC().UnixNano())
196 | domain := "ABEFHKMNPQRSUVWXYZ#$&"
197 | rng := 4
198 | newID := ""
199 |
200 | for i := 0; i < rng; i++ {
201 | newID += string(domain[rand.Intn(len(domain))])
202 | }
203 |
204 | return newID
205 | }
206 |
207 | func CreateNewCaptcha() error {
208 | id := RandomID(8)
209 | file := "public/" + id + ".png"
210 |
211 | for true {
212 | if _, err := os.Stat("./" + file); err == nil {
213 | id = RandomID(8)
214 | file = "public/" + id + ".png"
215 | } else {
216 | break
217 | }
218 | }
219 |
220 | var pattern string
221 |
222 | captcha := Captcha()
223 | rnd := fmt.Sprintf("%d", rand.Intn(3))
224 | srnd := string(rnd)
225 |
226 | switch srnd {
227 | case "0":
228 | pattern = "pattern:verticalbricks"
229 | break
230 |
231 | case "1":
232 | pattern = "pattern:verticalsaw"
233 | break
234 |
235 | case "2":
236 | pattern = "pattern:hs_cross"
237 | break
238 |
239 | }
240 |
241 | cmd := exec.Command("convert", "-size", "200x98", pattern, "-transparent", "white", file)
242 | cmd.Stderr = os.Stderr
243 |
244 | if err := cmd.Run(); err != nil {
245 | return MakeError(err, "CreateNewCaptcha")
246 | }
247 |
248 | cmd = exec.Command("convert", file, "-fill", "blue", "-pointsize", "62", "-annotate", "+0+70", captcha, "-tile", "pattern:left30", "-gravity", "center", "-transparent", "white", file)
249 | cmd.Stderr = os.Stderr
250 |
251 | if err := cmd.Run(); err != nil {
252 | return MakeError(err, "CreateNewCaptcha")
253 | }
254 |
255 | rnd = fmt.Sprintf("%d", rand.Intn(24)-12)
256 | cmd = exec.Command("convert", file, "-rotate", rnd, "-wave", "5x35", "-distort", "Arc", "20", "-wave", "2x35", "-transparent", "white", file)
257 | cmd.Stderr = os.Stderr
258 |
259 | if err := cmd.Run(); err != nil {
260 | return MakeError(err, "CreateNewCaptcha")
261 | }
262 |
263 | var verification Verify
264 |
265 | verification.Type = "captcha"
266 | verification.Code = captcha
267 | verification.Identifier = file
268 |
269 | return verification.Create()
270 | }
271 |
272 | func GetRandomCaptcha() (string, error) {
273 | var verify string
274 |
275 | query := `select identifier from verification where type='captcha' order by random() limit 1`
276 | if err := config.DB.QueryRow(query).Scan(&verify); err != nil {
277 | return verify, MakeError(err, "GetRandomCaptcha")
278 | }
279 |
280 | return verify, nil
281 | }
282 |
283 | func GetCaptchaTotal() (int, error) {
284 | var count int
285 |
286 | query := `select count(*) from verification where type='captcha'`
287 | if err := config.DB.QueryRow(query).Scan(&count); err != nil {
288 | return count, MakeError(err, "GetCaptchaTotal")
289 | }
290 |
291 | return count, nil
292 | }
293 |
294 | func GetCaptchaCode(verify string) (string, error) {
295 | var code string
296 |
297 | query := `select code from verification where identifier=$1 limit 1`
298 | if err := config.DB.QueryRow(query, verify).Scan(&code); err != nil {
299 | return code, MakeError(err, "GetCaptchaCodeDB")
300 | }
301 |
302 | return code, nil
303 | }
304 |
305 | func DeleteCaptchaCode(verify string) error {
306 | query := `delete from verification where identifier=$1`
307 | _, err := config.DB.Exec(query, verify)
308 |
309 | if err != nil {
310 | return MakeError(err, "DeleteCaptchaCode")
311 | }
312 |
313 | err = os.Remove("./" + verify)
314 | return MakeError(err, "DeleteCaptchaCode")
315 | }
316 |
317 | func GetVerificationByCode(code string) (Verify, error) {
318 | var verify Verify
319 |
320 | query := `select type, identifier, code, board from boardaccess where code=$1`
321 | if err := config.DB.QueryRow(query, code).Scan(&verify.Type, &verify.Identifier, &verify.Code, &verify.Board); err != nil {
322 | return verify, MakeError(err, "GetVerificationByCode")
323 | }
324 |
325 | return verify, nil
326 | }
327 |
328 | func GetVerificationByEmail(email string) (Verify, error) {
329 | var verify Verify
330 |
331 | query := `select type, identifier, code, board from boardaccess where identifier=$1`
332 | if err := config.DB.QueryRow(query, email).Scan(&verify.Type, &verify.Identifier, &verify.Code, &verify.Board); err != nil {
333 | return verify, nil
334 | }
335 |
336 | return verify, nil
337 | }
338 |
339 | func GetVerify(access string) (Verify, error) {
340 | verify, err := GetVerificationByCode(access)
341 |
342 | if err != nil {
343 | return verify, MakeError(err, "GetVerify")
344 | }
345 |
346 | if verify.Identifier == "" {
347 | verify, err = GetVerificationByEmail(access)
348 | }
349 |
350 | return verify, MakeError(err, "GetVerify")
351 | }
352 |
353 | func HasAuthCooldown(auth string) (bool, error) {
354 | var current VerifyCooldown
355 | var err error
356 |
357 | if current, err = VerifyCooldownCurrent(auth); err != nil {
358 | return false, MakeError(err, "HasAuthCooldown")
359 | }
360 |
361 | if current.Time > 0 {
362 | return true, nil
363 | }
364 |
365 | return false, nil
366 | }
367 |
368 | func HasAuth(code string, board string) (bool, string) {
369 | verify, err := GetVerificationByCode(code)
370 |
371 | if err != nil {
372 | return false, ""
373 | }
374 |
375 | if res, _type := verify.HasBoardAccess(); verify.Board == config.Domain || (res && verify.Board == board) {
376 | return true, _type
377 | }
378 |
379 | return false, ""
380 | }
381 |
382 | func IsEmailSetup() bool {
383 | return config.SiteEmail != "" || config.SiteEmailPassword != "" || config.SiteEmailServer != "" || config.SiteEmailPort != ""
384 | }
385 |
386 | func VerficationCooldown() error {
387 | query := `select identifier, code, time from verificationcooldown`
388 | rows, err := config.DB.Query(query)
389 |
390 | if err != nil {
391 | return MakeError(err, "VerficationCooldown")
392 | }
393 |
394 | defer rows.Close()
395 | for rows.Next() {
396 | var verify VerifyCooldown
397 |
398 | if err := rows.Scan(&verify.Identifier, &verify.Code, &verify.Time); err != nil {
399 | return MakeError(err, "VerficationCooldown")
400 | }
401 |
402 | nTime := verify.Time - 1
403 | query = `update set time=$1 where identifier=$2`
404 |
405 | if _, err := config.DB.Exec(query, nTime, verify.Identifier); err != nil {
406 | return MakeError(err, "VerficationCooldown")
407 | }
408 |
409 | VerficationCooldownRemove()
410 | }
411 |
412 | return nil
413 | }
414 |
415 | func VerficationCooldownRemove() error {
416 | query := `delete from verificationcooldown where time < 1`
417 | _, err := config.DB.Exec(query)
418 |
419 | return MakeError(err, "VerficationCooldownRemove")
420 | }
421 |
422 | func VerifyCooldownCurrent(auth string) (VerifyCooldown, error) {
423 | var current VerifyCooldown
424 |
425 | query := `select identifier, code, time from verificationcooldown where code=$1`
426 | if err := config.DB.QueryRow(query, auth).Scan(¤t.Identifier, ¤t.Code, ¤t.Time); err != nil {
427 | query := `select identifier, code, time from verificationcooldown where identifier=$1`
428 | if err := config.DB.QueryRow(query, auth).Scan(¤t.Identifier, ¤t.Code, ¤t.Time); err != nil {
429 | return current, nil
430 | }
431 |
432 | return current, nil
433 | }
434 |
435 | return current, nil
436 | }
437 |
438 | func GetPasswordFromSession(ctx *fiber.Ctx) (string, string) {
439 | cookie := ctx.Cookies("session_token")
440 | parts := strings.Split(cookie, "|")
441 |
442 | if len(parts) > 1 {
443 | return parts[0], parts[1]
444 | }
445 |
446 | return "", ""
447 | }
448 |
449 | func MakeCaptchas(total int) error {
450 | dbtotal, err := GetCaptchaTotal()
451 |
452 | if err != nil {
453 | return MakeError(err, "MakeCaptchas")
454 | }
455 |
456 | difference := total - dbtotal
457 |
458 | for i := 0; i < difference; i++ {
459 | if err := CreateNewCaptcha(); err != nil {
460 | return MakeError(err, "MakeCaptchas")
461 | }
462 | }
463 |
464 | return nil
465 | }
466 |
--------------------------------------------------------------------------------
/views/403.html:
--------------------------------------------------------------------------------
1 |
{{$e.Content}}
12 |18 | {{ end }} 19 | | No. | 20 |Excerpt | 21 |22 | |
---|---|---|---|
[Pop] | 28 | {{ end }} 29 |{{ shortURL $board.Actor.Outbox $e.Id }} | 30 | 31 |[View] | 32 ||
[Pop] | 37 | {{ end }} 38 |{{ shortURL $board.Actor.Outbox $e.Id }} | 39 | 40 |[View] | 41 |
fchan, short for FChannel, is a federated image board based on ActivityPub, a protocol which allows social sites like social media and image boards to be decentralized. Boards across sites which are on the opposite sides of the Globe can be connected and feeds can be shared or followed. It pulls likeness from other chans for ease of familiarity and use.
You can get the source code on https://github.com/FChannel0 which is available under AGPLv3, which means that you can modify the source code of fchan however you like as long as you share your source code with everyone else. We appreciate and encourage any positive contributions to the source code!
14 | 15 |The "Options" field can be used for special options when posting.
17 |A tripcode is a way to uniquely identify yourself on an imageboard. This is the closest you will get to registering. There are two kinds of tripcodes that can identify yourself with, however, it's recommended that you use secure tripcodes only if you take your identification number seriously.
24 |Use the greater-than symbol (>) to quote strings of text. Use double (>>) followed by the URL id of the post you are referencing or click on the unique ID of the post (for example, FIDV40Q2) if you want to reference a post (keep in mind that this will be changed later for better use).
31 | 32 |Click the "No." next to the post to view its thread.
34 | 35 |The maximum file size is 7 MiB (mebibyte). The supported file types are:
37 |A version of the frontend with no JavaScript will be made eventually. Current version requires it as it is needed for some basic functionality. There are no external libraries used by the frontend, just basic selection of DOM elements and modifying their styling. Perhaps (You) could contribute a frontend that uses no JavaScript?
51 | 52 |Sequential ID numbers have run their course, random base36 is better.
54 | 55 |Soon™.
57 |All trademarks and copyrights on this page are owned by their respective parties.
61 |{{ .page.PreferredUsername }} is a federated image board based on ActivityPub. The current version of the code running on the server is still a work-in-progress product, expect a bumpy ride for the time being. Get the server code here: https://github.com/FChannel0. Current known instances can be found here.
4 | 5 | {{ if .page.Boards }} 6 | {{ $l := len .page.Boards }} 7 |{{$e.Content}}
38 |20 | | 27 | 28 | {{ if gt (len .page.Posts) 0 }} 29 | {{ if eq (index .page.Posts 0).Type "Note" }} 30 | 26 |31 | [ 32 | ] | 33 | {{ end }} 34 | 35 |36 | {{ $replies := (index .page.Posts 0).Replies }} 37 | {{ $replies.TotalItems }} / {{ $replies.TotalImgs }} 38 | | 39 | {{ end }} 40 |
All trademarks and copyrights on this page are owned by their respective parties.
4 |v0.1.1
8 | Theme: 9 | 14 |{{ parseContent $board.Actor $opId .Content $thread .Id $page.PostType }}
55 | {{ if .Replies }} 56 | {{ $replies := .Replies }} 57 | {{ if gt $replies.TotalItems 5 }} 58 | {{ if gt $len 1 }} 59 | {{ $replies.TotalItems }} replies{{ if gt $replies.TotalImgs 0}} and {{ $replies.TotalImgs }} images{{ end }}, Click here to view all. 60 | {{ end }} 61 | {{ end }} 62 | {{ range $replies.OrderedItems }} 63 |{{ parseContent $board.Actor $opId .Content $thread .Id $page.PostType }}
118 |{{ .Board.Summary }}
4 | {{ $len := len .Posts }} 5 | {{ if eq $len 0 }} 6 | {{ if eq .PostType "new" }} 7 |{{ .page.Board.Summary }}
4 |