├── .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 |
2 |

403

3 |

{{ .message }}

4 |

5 | Click here to return to the index. 6 |

7 |
8 | -------------------------------------------------------------------------------- /views/404.html: -------------------------------------------------------------------------------- 1 |
2 |

404

3 |

{{ .message }}

4 |

5 | Click here to return to the index. 6 |

7 |
8 | -------------------------------------------------------------------------------- /views/admin.html: -------------------------------------------------------------------------------- 1 |
2 |

Add Board

3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | 15 |
16 | 22 |
23 | 24 |
25 |

Subscribed

26 |
27 |
28 | 29 |
30 | 37 |
38 | 39 | 47 | 48 |
49 |

Reported

50 | 69 |
70 | 71 |
72 |

Create News

73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | 81 |
82 |

Regex Post Blacklist

83 |
84 |
85 |
86 |
87 |
88 |
89 | {{ if .page.PostBlacklist }} 90 | 95 | {{ end }} 96 |
97 | 98 | {{ template "partials/footer" .page }} 99 | {{ template "partials/general_scripts" .page }} 100 | -------------------------------------------------------------------------------- /views/anews.html: -------------------------------------------------------------------------------- 1 |
2 |

{{ .page.Title }}

3 | 4 |
5 | {{ $page := .page }} 6 | {{ range $i, $e := .page.NewsItems }} 7 |
8 |

{{unixtoreadable $e.Time}} - {{$e.Title}}{{ if $page.Board.ModCred }} [Delete] {{end}}

9 |
10 | 11 |

{{$e.Content}}

12 |
13 | {{ end }} 14 |
15 |
16 | -------------------------------------------------------------------------------- /views/archive.html: -------------------------------------------------------------------------------- 1 | {{ template "partials/top" .page }} 2 | 3 | {{ $board := .page.Board }} 4 | 5 |
6 | 11 |
12 | 13 | {{ if .page.Posts }} 14 | 15 | 16 | {{ if eq $board.ModCred $board.Domain $board.Actor.Id }} 17 | 18 | {{ end }} 19 | 20 | 21 | 22 | 23 | {{ range $i, $e := .page.Posts }} 24 | {{ if mod $i 2 }} 25 | 26 | {{ if eq $board.ModCred $board.Domain $board.Actor.Id }} 27 | 28 | {{ end }} 29 | 30 | 31 | 32 | 33 | {{ else }} 34 | 35 | {{ if eq $board.ModCred $board.Domain $board.Actor.Id }} 36 | 37 | {{ end }} 38 | 39 | 40 | 41 | 42 | {{ end }} 43 | {{ end }} 44 |
No.Excerpt
[Pop]{{ shortURL $board.Actor.Outbox $e.Id }}{{ shortExcerpt $e }}[View]
[Pop]{{ shortURL $board.Actor.Outbox $e.Id }}{{ shortExcerpt $e }}[View]
45 | {{ end }} 46 | 47 |
48 | 49 | 54 | 55 |
56 | 57 | {{ template "partials/bottom" .page }} 58 | {{ template "partials/footer" .page }} 59 | {{ template "partials/general_scripts" .page }} 60 | {{ template "partials/post_scripts" .page }} 61 | -------------------------------------------------------------------------------- /views/catalog.html: -------------------------------------------------------------------------------- 1 | {{ template "partials/top" .page }} 2 | 3 | {{ $board := .page.Board }} 4 |
5 | 6 | 11 | 12 |
13 | 14 |
15 | {{ range .page.Posts }} 16 |
17 | {{ if eq $board.ModCred $board.Domain $board.Actor.Id }} 18 | [Delete Post] 19 | {{ end }} 20 | {{ if .Attachment }} 21 | {{ if eq $board.ModCred $board.Domain $board.Actor.Id }} 22 | [Delete Attachment] 23 | [Mark Sensitive] 24 | {{ end }} 25 | 26 | 32 | 33 |
{{ if .Sticky }}{{ end }}{{ if .Locked }}{{ end }}
{{ parseAttachment . true }}
34 |
35 | 58 | {{ end }} 59 | 60 |
61 | {{ $replies := .Replies }} 62 | {{ if $replies }} 63 | R: {{ $replies.TotalItems }}{{ if $replies.TotalImgs }}/ A: {{ $replies.TotalImgs }}{{ end }} 64 | {{ end }} 65 | {{ if .Name }} 66 |
67 | {{ .Name }} 68 | {{ end }} 69 | 70 | {{ if .Content }} 71 |
72 | {{.Content}} 73 | {{ end }} 74 |
75 |
76 |
77 | {{ end }} 78 |
79 | 80 |
81 | 82 | 87 | 88 |
89 | 90 | {{ template "partials/footer" .page }} 91 | {{ template "partials/general_scripts" .page }} 92 | -------------------------------------------------------------------------------- /views/clover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FChannel0/FChannel-Server/301c160caf31d23ec53754df71c779c552bde891/views/clover.png -------------------------------------------------------------------------------- /views/css/themes/default.css: -------------------------------------------------------------------------------- 1 | a, a:link, a:visited, a:hover, a:active { 2 | text-decoration: none 3 | } 4 | 5 | a:link, a:visited, a:active { 6 | color: black; 7 | } 8 | 9 | a:hover { 10 | color: #de0808; 11 | } 12 | 13 | body { 14 | background-color: #eef2fe; 15 | color: black; 16 | } 17 | 18 | body.nsfw { 19 | background-color: #ffffee; 20 | color: #820404 21 | } 22 | 23 | h1, h2, h3, h4, h5, h6 { 24 | color: #af0a0f; 25 | } 26 | 27 | .popup-box, #report-box { 28 | border: 4px solid #d3caf0; 29 | background-color: #eff5ff; 30 | } 31 | 32 | .nsfw .popup-box, .nsfw #report-box { 33 | border: 4px solid #f0e2d9; 34 | background-color: #f9f9e0; 35 | } 36 | 37 | .box { 38 | background-color: #eff5ff; 39 | } 40 | 41 | .nsfw .box { 42 | background-color: #f9f9e0; 43 | } 44 | 45 | .box-alt { 46 | background-color: #d3caf0; 47 | } 48 | 49 | .nsfw .box-alt { 50 | background-color: #f0e2d9; 51 | } 52 | 53 | 54 | .quote { 55 | color: #789922; 56 | } 57 | 58 | .post { 59 | background-color: #d5daf0; 60 | } 61 | 62 | .nsfw .post { 63 | background-color: #f0e0d6; 64 | } 65 | 66 | :target > div > .post { 67 | background-color: #d6bad0; 68 | } 69 | 70 | .nsfw :target > div > .post { 71 | background-color: #f0c0b0; 72 | } 73 | 74 | .title { 75 | color: #0f0c5d; 76 | } 77 | 78 | .name, .tripcode { 79 | color: #117743; 80 | } 81 | 82 | a.reply { 83 | color: #af0a0f; 84 | text-decoration: 1px underline; 85 | } 86 | 87 | .replyLink { 88 | color: #000080; 89 | font-size: 0.8em; 90 | } 91 | 92 | #newpostbtn { 93 | text-align: center; 94 | margin-top: 80px; 95 | } 96 | 97 | #postForm { 98 | margin: auto; 99 | } 100 | 101 | #postForm tr > td:first-child { 102 | background-color: #98e; 103 | border: 1px black; 104 | padding-left: 0.5em; 105 | padding-right: 0.5em; 106 | } 107 | 108 | .nsfw #postForm tr > td:first-child { 109 | background-color: #ea8; 110 | } 111 | 112 | #postForm input[type="text"], 113 | #postForm textarea, 114 | #reply-name, #reply-options, #reply-comment { 115 | box-sizing: border-box; 116 | -webkit-box-sizing:border-box; 117 | -moz-box-sizing: border-box; 118 | } 119 | 120 | #postForm input[type="text"], 121 | #postForm textarea, 122 | #reply-name, #reply-options, #reply-comment { 123 | box-sizing: border-box; 124 | -webkit-box-sizing:border-box; 125 | -moz-box-sizing: border-box; 126 | } 127 | 128 | #reply-comment { 129 | min-width: 300px; 130 | width: 396px; 131 | height: 200px; 132 | } 133 | 134 | #reply-name { 135 | width: 75%; 136 | float: left; 137 | } 138 | 139 | #reply-options { 140 | width: 25%; 141 | float: right; 142 | } 143 | 144 | #reply-header { 145 | display: inline-block; 146 | width: 100%; 147 | cursor: move; 148 | } 149 | 150 | #postForm #captcha { 151 | display: block; 152 | width: 100%; 153 | } 154 | 155 | .popup-box { 156 | position: fixed; 157 | min-width: 300px; 158 | width: min-content; 159 | z-index: 9; 160 | display: block; 161 | } 162 | 163 | #report-box { 164 | min-width: 300px; 165 | width: min-content; 166 | z-index: 9; 167 | display: block; 168 | } 169 | 170 | /* TODO: rename */ 171 | .box2 { 172 | border: 4px solid #f0e2d9; 173 | background-color: #f9f9e0; 174 | } 175 | 176 | .newsbox { 177 | padding: 25px; 178 | border: 4px solid #f0e2d9; 179 | background-color: #f9f9e0; 180 | } 181 | 182 | .newsbox h2 { 183 | margin: 0; 184 | padding: 0; 185 | } 186 | 187 | .newsbox-news { 188 | text-align: left; 189 | margin-top: 25px; 190 | padding: 25px; 191 | } 192 | 193 | .newsbox-news p, 194 | .newsbox-news h3 { 195 | margin: 0; 196 | } 197 | 198 | #stopTablePost { 199 | float: right; 200 | display: none; 201 | } 202 | 203 | #boardGrid { 204 | display: grid; 205 | grid-auto-columns: 1fr; 206 | border: 4px solid #f0e2d9; 207 | background-color: #f9f9e0; 208 | } 209 | 210 | #boardGridHeader { 211 | border-bottom: 2px solid #820404; 212 | display: inline-grid; 213 | } 214 | 215 | .boardGridCell { 216 | white-space: nowrap; 217 | display: inline-grid; 218 | text-align: left; 219 | padding: 5px; 220 | } 221 | 222 | /* these may or may not work. my CSS is poor so i just kinda did stuff until it worked. */ 223 | .boardGridCell:nth-child(-n+4) { 224 | border-top: none; 225 | } 226 | 227 | .boardGridCell:nth-child(3n+2) { 228 | border-left: none; 229 | } 230 | 231 | #threadfooter { 232 | width: 100%; 233 | table-layout: fixed; 234 | border-collapse: collapse; 235 | } 236 | 237 | #threadfooter td { 238 | padding: 0; 239 | margin: 0; 240 | } 241 | 242 | #threadfooter #threadStats { 243 | float: right; 244 | } 245 | 246 | #navlinks, #boardlinks { 247 | padding: 0; 248 | margin: 0; 249 | } 250 | 251 | #navlinks > li, 252 | #boardlinks > li { 253 | display: inline; 254 | } 255 | -------------------------------------------------------------------------------- /views/css/themes/gruvbox.css: -------------------------------------------------------------------------------- 1 | a, a:link, a:visited, a:active { 2 | color: #b16286; 3 | text-decoration: none 4 | } 5 | 6 | a.reply { 7 | color: #cc241d; 8 | text-decoration: 1px underline; 9 | } 10 | 11 | a:hover.reply { 12 | color: #fb4934; 13 | } 14 | 15 | body { 16 | background: #282828; 17 | color: #ebdbb2; 18 | 19 | font-family: monospace, sans-serif; 20 | font-size: 0.9em; 21 | } 22 | 23 | .popup-box, #report-box { 24 | border: 4px solid #928374; 25 | background-color: #3c3836; 26 | } 27 | 28 | .box, .box-alt { 29 | background-color: #3c3836; 30 | } 31 | 32 | .quote { 33 | color: #98971a; 34 | } 35 | 36 | .post { 37 | background-color: #1d2021; 38 | } 39 | 40 | :target > div > .post { 41 | background-color: #504945; 42 | } 43 | 44 | .subject { 45 | color: #458588; 46 | } 47 | 48 | .name { 49 | color: #b8bb26; 50 | } 51 | 52 | .tripcode { 53 | color: #689d6a; 54 | } 55 | 56 | h1,h2,h3,h4,h5,h6 { 57 | color: #fb4934; 58 | margin-bottom: 0.1em; 59 | } 60 | 61 | .replyLink { 62 | color: #83a598; 63 | font-size: 0.8em; 64 | } 65 | 66 | #newpostbtn { 67 | text-align: center; 68 | margin-top: 80px; 69 | } 70 | 71 | input[type="text"] { 72 | -webkit-appearance: none; 73 | -webkit-border-radius: 0; 74 | } 75 | 76 | #postForm { 77 | border: 4px solid #928374; 78 | background-color: #3c3836; 79 | margin: auto; 80 | } 81 | 82 | #postForm tr > td:first-child { 83 | background-color: #504945; 84 | padding-left: 0.5em; 85 | padding-right: 0.5em; 86 | } 87 | 88 | #postForm input[type="text"], 89 | #postForm textarea, 90 | #reply-name, #reply-options, #reply-comment { 91 | background-color: #504945; 92 | color: #ebdbb2; 93 | border: 0; 94 | border-bottom: 2px solid #3c3836; 95 | font-family: monospace, sans-serif; 96 | 97 | box-sizing: border-box; 98 | -webkit-box-sizing:border-box; 99 | -moz-box-sizing: border-box; 100 | } 101 | 102 | #postForm input[type="text"]:focus, 103 | #postForm textarea:focus, 104 | #reply-name:focus, #reply-options:focus, #reply-comment:focus { 105 | outline: none; 106 | } 107 | 108 | #reply-comment { 109 | min-width: 300px; 110 | width: 396px; 111 | height: 200px; 112 | } 113 | 114 | #reply-name { 115 | width: 75%; 116 | float: left; 117 | } 118 | 119 | #reply-options { 120 | width: 25%; 121 | border-left: 2px solid #3c3836; 122 | float: right; 123 | } 124 | 125 | #reply-header { 126 | display: inline-block; 127 | width: 100%; 128 | cursor: move; 129 | } 130 | 131 | #postForm #captcha { 132 | display: block; 133 | width: 100%; 134 | } 135 | 136 | .popup-box { 137 | position: fixed; 138 | min-width: 300px; 139 | width: min-content; 140 | z-index: 9; 141 | display: block; 142 | } 143 | 144 | #report-box { 145 | min-width: 300px; 146 | width: min-content; 147 | z-index: 9; 148 | display: block; 149 | } 150 | 151 | /* TODO: rename */ 152 | .box2 { 153 | border: 4px solid #928374; 154 | background-color: #3c3836; 155 | } 156 | 157 | .newsbox { 158 | padding: 25px; 159 | border: 4px solid #928374; 160 | background-color: #3c3836; 161 | } 162 | 163 | .newsbox h2 { 164 | margin: 0; 165 | padding: 0; 166 | } 167 | 168 | .newsbox-news { 169 | text-align: left; 170 | background-color: #504945; 171 | margin-top: 25px; 172 | padding: 25px; 173 | } 174 | 175 | .newsbox-news p, 176 | .newsbox-news h3 { 177 | margin: 0; 178 | } 179 | 180 | #stopTablePost { 181 | float: right; 182 | display: none; 183 | } 184 | 185 | #boardGrid { 186 | display: grid; 187 | grid-auto-columns: 1fr; 188 | border: 4px solid #928374; 189 | background-color: #3c3836; 190 | } 191 | 192 | #boardGridHeader { 193 | border-bottom: 2px solid #928374; 194 | display: inline-grid; 195 | } 196 | 197 | .boardGridCell { 198 | white-space: nowrap; 199 | display: inline-grid; 200 | text-align: left; 201 | padding: 5px; 202 | } 203 | 204 | /* these may or may not work. my CSS is poor so i just kinda did stuff until it worked. */ 205 | .boardGridCell:nth-child(-n+4) { 206 | border-top: none; 207 | } 208 | 209 | .boardGridCell:nth-child(3n+2) { 210 | border-left: none; 211 | } 212 | 213 | #threadfooter { 214 | width: 100%; 215 | table-layout: fixed; 216 | border-collapse: collapse; 217 | } 218 | 219 | #threadfooter td { 220 | padding: 0; 221 | margin: 0; 222 | } 223 | 224 | #threadfooter #threadStats { 225 | float: right; 226 | } 227 | 228 | #navlinks, #boardlinks { 229 | padding: 0; 230 | margin: 0; 231 | } 232 | 233 | #navlinks > li, 234 | #boardlinks > li { 235 | display: inline; 236 | } 237 | 238 | hr { 239 | border: 1px solid #928374; 240 | } 241 | -------------------------------------------------------------------------------- /views/faq.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | [Back] 10 |

FAQ

11 |
12 |

What is fchan?

13 |

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 |

What are the "Options" used for when posting?

16 |

The "Options" field can be used for special options when posting.

17 | 22 |

What is a "tripcode"?

23 |

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 |

28 | 29 |

How do I quote?

30 |

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 | 33 |

Click the "No." next to the post to view its thread.

34 | 35 |

What kind of files can I upload?

36 |

The maximum file size is 7 MiB (mebibyte). The supported file types are:

37 | 48 | 49 |

Why use JavaScript?

50 |

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 |

Why do the posts not have sequential ID numbers?

53 |

Sequential ID numbers have run their course, random base36 is better.

54 | 55 |

ActivityPub specific examples

56 |

Soon™.

57 |
58 |
59 | [Home][Rules][FAQ] 60 |

All trademarks and copyrights on this page are owned by their respective parties.

61 |
62 | 63 | 64 | -------------------------------------------------------------------------------- /views/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FChannel0/FChannel-Server/301c160caf31d23ec53754df71c779c552bde891/views/favicon.png -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 |
2 |

{{ .page.Title }}

3 |

{{ .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 |
8 |
9 | {{ if lt $l 2 }} 10 |
Local boards
11 | {{ else if eq $l 2 }} 12 |
Local boards
13 | {{ else }} 14 |
Local boards
15 | {{ end }} 16 | {{ range .page.Boards }} 17 | 18 | {{ end }} 19 | {{ if gt $l 2 }} 20 | {{ range .page.BoardRemainer }} 21 |
22 | {{ end }} 23 | {{ end }} 24 |
25 |
26 | {{ end }} 27 | 28 | {{ if .page.NewsItems }} 29 |
30 |

{{ .page.PreferredUsername }} News

31 | {{ $page := .page }} 32 | {{ range $i, $e := .page.NewsItems }} 33 |
34 |

{{unixtoreadable $e.Time}} - {{$e.Title}}{{ if $page.Board.ModCred }} [Delete] {{end}}

35 |
36 | 37 |

{{$e.Content}}

38 |
39 | {{ end }} 40 |
41 | {{ end }} 42 |
43 | 44 | {{ template "partials/footer" .page }} 45 | {{ template "partials/general_scripts" .page }} 46 | -------------------------------------------------------------------------------- /views/js/footerscript.js: -------------------------------------------------------------------------------- 1 | var imgs = document.querySelectorAll('#img'); 2 | var imgArray = [].slice.call(imgs); 3 | 4 | imgArray.forEach(function(img, i){ 5 | img.addEventListener("click", function(e){ 6 | var id = img.getAttribute("id"); 7 | var media = document.getElementById("media-" + id); 8 | var sensitive = document.getElementById("sensitive-" + id); 9 | 10 | if(img.getAttribute("enlarge") == "0") 11 | { 12 | var attachment = img.getAttribute("attachment"); 13 | img.setAttribute("enlarge", "1"); 14 | img.setAttribute("style", "float: left; margin-right: 10px; cursor: pointer;"); 15 | img.src = attachment; 16 | } 17 | else 18 | { 19 | var preview = img.getAttribute("preview"); 20 | img.setAttribute("enlarge", "0"); 21 | if(img.getAttribute("main") == 1) 22 | { 23 | img.setAttribute("style", "float: left; margin-right: 10px; max-width: 250px; max-height: 250px; cursor: pointer;"); 24 | img.src = preview; 25 | } 26 | else 27 | { 28 | img.setAttribute("style", "float: left; margin-right: 10px; max-width: 125px; max-height: 125px; cursor: pointer;"); 29 | img.src = preview; 30 | } 31 | } 32 | }); 33 | }); 34 | 35 | 36 | function viewLink(board, actor) { 37 | var posts = document.querySelectorAll('#view'); 38 | var postsArray = [].slice.call(posts); 39 | 40 | postsArray.forEach(function(p, i){ 41 | var id = p.getAttribute("post"); 42 | p.href = "/" + board + "/" + shortURL(actor, id); 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /views/js/posts.js: -------------------------------------------------------------------------------- 1 | function startNewPost(){ 2 | var el = document.getElementById("newpostbtn"); 3 | el.style="display:none;"; 4 | el.setAttribute("state", "1"); 5 | document.getElementById("newpost").style = ""; 6 | document.getElementById("stopTablePost").style = "cursor: pointer; display:unset;"; 7 | sessionStorage.setItem("newpostState", true); 8 | } 9 | 10 | function stopNewPost(){ 11 | var el = document.getElementById("newpostbtn"); 12 | el.style="display:block;margin-bottom:100px;"; 13 | el.setAttribute("state", "0"); 14 | document.getElementById("newpost").style = "display: none;"; 15 | sessionStorage.setItem("newpostState", false); 16 | } 17 | 18 | function shortURL(actorName, url) 19 | { 20 | re = /.+\//g; 21 | temp = re.exec(url); 22 | 23 | var output; 24 | 25 | if(stripTransferProtocol(temp[0]) == stripTransferProtocol(actorName) + "/") 26 | { 27 | var short = url.replace("https://", ""); 28 | short = short.replace("http://", ""); 29 | short = short.replace("www.", ""); 30 | 31 | var re = /^.{3}/g; 32 | 33 | var u = re.exec(short); 34 | 35 | re = /\w+$/g; 36 | 37 | output = re.exec(short); 38 | }else{ 39 | var short = url.replace("https://", ""); 40 | short = short.replace("http://", ""); 41 | short = short.replace("www.", ""); 42 | 43 | var re = /^.{3}/g; 44 | 45 | var u = re.exec(short); 46 | 47 | re = /\w+$/g; 48 | 49 | u = re.exec(short); 50 | 51 | str = short.replace(/\/+/g, " "); 52 | 53 | str = str.replace(u, " ").trim(); 54 | 55 | re = /(\w|[!@#$%^&*<>])+$/; 56 | 57 | v = re.exec(str); 58 | 59 | output = "f" + v[0] + "-" + u 60 | } 61 | 62 | return output; 63 | } 64 | 65 | function getBoardId(url) 66 | { 67 | var re = /\/([^/\n]+)(.+)?/gm; 68 | var matches = re.exec(url); 69 | return matches[1]; 70 | } 71 | 72 | function convertContent(actorName, content, opid) 73 | { 74 | var re = /(>>)(https?:\/\/)?(www\.)?.+\/\w+/gm; 75 | var match = content.match(re); 76 | var newContent = content; 77 | if(match) 78 | { 79 | match.forEach(function(quote, i){ 80 | var link = quote.replace('>>', ''); 81 | var isOP = ""; 82 | if(link == opid) 83 | { 84 | isOP = " (OP)"; 85 | } 86 | 87 | var q = link; 88 | 89 | if(document.getElementById(link + "-content") != null) { 90 | q = document.getElementById(link + "-content").innerText; 91 | q = q.replaceAll('>', '/\>'); 92 | q = q.replaceAll('"', ''); 93 | q = q.replaceAll("'", ""); 94 | } 95 | newContent = newContent.replace(quote, '>>' + shortURL(actorName, link) + isOP + ''); 96 | 97 | }); 98 | } 99 | 100 | re = /^(\s+)?>.+/gm; 101 | 102 | match = newContent.match(re); 103 | if(match) 104 | { 105 | match.forEach(function(quote, i) { 106 | 107 | newContent = newContent.replace(quote, '' + quote + ''); 108 | }); 109 | } 110 | 111 | return newContent.replaceAll('/\>', '>'); 112 | } 113 | 114 | function convertContentNoLink(actorName, content, opid) 115 | { 116 | var re = /(>>)(https?:\/\/)?(www\.)?.+\/\w+/gm; 117 | var match = content.match(re); 118 | var newContent = content; 119 | if(match) 120 | { 121 | match.forEach(function(quote, i){ 122 | var link = quote.replace('>>', ''); 123 | var isOP = ""; 124 | if(link == opid) 125 | { 126 | isOP = " (OP)"; 127 | } 128 | 129 | var q = link; 130 | 131 | if(document.getElementById(link + "-content") != null) { 132 | q = document.getElementById(link + "-content").innerText; 133 | } 134 | 135 | newContent = newContent.replace(quote, '>>' + shortURL(actorName, link) + isOP); 136 | }); 137 | } 138 | newContent = newContent.replaceAll("'", ""); 139 | return newContent.replaceAll('"', ''); 140 | } 141 | 142 | function closeReply() 143 | { 144 | document.getElementById("reply-box").style.display = "none"; 145 | document.getElementById("reply-comment").value = ""; 146 | 147 | sessionStorage.setItem("element-closed-reply", true); 148 | } 149 | 150 | function closeReport() 151 | { 152 | document.getElementById("report-box").style.display = "none"; 153 | document.getElementById("report-comment").value = ""; 154 | 155 | sessionStorage.setItem("element-closed-report", true); 156 | } 157 | 158 | function quote(actorName, opid, id) 159 | { 160 | sessionStorage.setItem("element-closed-reply", false); 161 | var box = document.getElementById("reply-box"); 162 | var header = document.getElementById("reply-header"); 163 | var header_text = document.getElementById("reply-header-text"); 164 | var comment = document.getElementById("reply-comment"); 165 | var inReplyTo = document.getElementById("inReplyTo-box"); 166 | 167 | var w = window.innerWidth / 2 - 200; 168 | var h = 300; //document.getElementById(id + "-content").offsetTop - 348; 169 | 170 | const boxStyle = "top: " + h + "px; left: " + w + "px;"; 171 | box.setAttribute("style", boxStyle); 172 | sessionStorage.setItem("element-reply-style", boxStyle); 173 | sessionStorage.setItem("reply-top", h); 174 | sessionStorage.setItem("reply-left", w); 175 | 176 | 177 | if (inReplyTo.value != opid) 178 | comment.value = ""; 179 | 180 | header_text.innerText = "Replying to Thread No. " + shortURL(actorName, opid); 181 | inReplyTo.value = opid; 182 | sessionStorage.setItem("element-reply-actor", actorName); 183 | sessionStorage.setItem("element-reply-id", inReplyTo.value); 184 | 185 | if(id != "reply") 186 | comment.value += ">>" + id + "\n"; 187 | sessionStorage.setItem("element-reply-comment", comment.value); 188 | 189 | dragElement(header); 190 | } 191 | 192 | function report(actorName, id) 193 | { 194 | sessionStorage.setItem("element-closed-report", false); 195 | var box = document.getElementById("report-box"); 196 | var header = document.getElementById("report-header"); 197 | var comment = document.getElementById("report-comment"); 198 | var inReplyTo = document.getElementById("report-inReplyTo-box"); 199 | 200 | var w = window.innerWidth / 2 - 200; 201 | var h = 300; //document.getElementById(id + "-content").offsetTop - 348; 202 | 203 | const boxStyle = "top: " + h + "px; left: " + w + "px;"; 204 | box.setAttribute("style", boxStyle); 205 | sessionStorage.setItem("element-report-style", boxStyle); 206 | sessionStorage.setItem("report-top", h); 207 | sessionStorage.setItem("report-left", w); 208 | 209 | header.innerText = "Report Post No. " + shortURL(actorName, id); 210 | inReplyTo.value = id; 211 | sessionStorage.setItem("element-report-actor", actorName); 212 | sessionStorage.setItem("element-report-id", id); 213 | 214 | dragElement(header); 215 | } 216 | 217 | var pos1, pos2, pos3, pos4; 218 | var elmnt; 219 | 220 | function closeDragElement(e) { 221 | // stop moving when mouse button is released: 222 | document.onmouseup = null; 223 | document.onmousemove = null; 224 | sessionStorage.setItem("eventhandler", false); 225 | } 226 | 227 | function elementDrag(e) { 228 | e = e || window.event; 229 | e.preventDefault(); 230 | // calculate the new cursor position: 231 | pos1 = pos3 - e.clientX; 232 | pos2 = pos4 - e.clientY; 233 | pos3 = e.clientX; 234 | pos4 = e.clientY; 235 | sessionStorage.setItem("pos1", pos1); 236 | sessionStorage.setItem("pos2", pos2); 237 | sessionStorage.setItem("pos3", pos3); 238 | sessionStorage.setItem("pos4", pos4); 239 | 240 | // set the element's new position: 241 | elmnt.parentElement.style.top = (elmnt.parentElement.offsetTop - pos2) + "px"; 242 | elmnt.parentElement.style.left = (elmnt.parentElement.offsetLeft - pos1) + "px"; 243 | if(elmnt.id.startsWith("report")){ 244 | sessionStorage.setItem("report-top", elmnt.parentElement.style.top); 245 | sessionStorage.setItem("report-left", elmnt.parentElement.style.left); 246 | }else if(elmnt.id.startsWith("reply")){ 247 | sessionStorage.setItem("reply-top", elmnt.parentElement.style.top); 248 | sessionStorage.setItem("reply-left", elmnt.parentElement.style.left); 249 | } 250 | } 251 | 252 | function dragMouseDown(e) { 253 | e = e || window.event; 254 | e.preventDefault(); 255 | 256 | // get the mouse cursor position at startup: 257 | pos3 = e.clientX; 258 | pos4 = e.clientY; 259 | sessionStorage.setItem("pos3", pos3); 260 | sessionStorage.setItem("pos4", pos4); 261 | 262 | elmnt = e.currentTarget; 263 | 264 | // call a function whenever the cursor moves: 265 | document.onmouseup = closeDragElement; 266 | document.onmousemove = elementDrag; 267 | sessionStorage.setItem("eventhandler", true); 268 | 269 | } 270 | 271 | function dragElement(elmnt) { 272 | elmnt.onmousedown = dragMouseDown; 273 | } 274 | 275 | const stateLoadHandler = function(event){ 276 | pos1 = parseInt(sessionStorage.getItem("pos1")); 277 | pos2 = parseInt(sessionStorage.getItem("pos2")); 278 | pos3 = parseInt(sessionStorage.getItem("pos3")); 279 | pos4 = parseInt(sessionStorage.getItem("pos4")); 280 | 281 | if(sessionStorage.getItem("element-closed-report") === "false"){ 282 | var box = document.getElementById("report-box"); 283 | var header = document.getElementById("report-header"); 284 | var comment = document.getElementById("report-comment"); 285 | var inReplyTo = document.getElementById("report-inReplyTo-box"); 286 | 287 | header.onmousedown = dragMouseDown; 288 | inReplyTo.value = parseInt(sessionStorage.getItem("element-report-id")); 289 | header.innerText = "Report Post No. " + shortURL(sessionStorage.getItem("element-report-actor"), sessionStorage.getItem("element-report-id")); 290 | comment.value = sessionStorage.getItem("element-report-comment"); 291 | 292 | box.setAttribute("style", sessionStorage.getItem("element-report-style")); 293 | 294 | box.style.top = sessionStorage.getItem("report-top"); 295 | box.style.left = sessionStorage.getItem("report-left"); 296 | 297 | if(sessionStorage.getItem("eventhandler") === "true"){ 298 | elmnt = header; 299 | document.onmouseup = closeDragElement; 300 | document.onmousemove = elementDrag; 301 | }else{ 302 | document.onmouseup = null; 303 | document.onmousemove = null; 304 | } 305 | } 306 | if(sessionStorage.getItem("element-closed-reply") === "false"){ 307 | var box = document.getElementById("reply-box"); 308 | var header = document.getElementById("reply-header"); 309 | var header_text = document.getElementById("reply-header-text"); 310 | var comment = document.getElementById("reply-comment"); 311 | var inReplyTo = document.getElementById("inReplyTo-box"); 312 | 313 | header.onmousedown = dragMouseDown; 314 | inReplyTo.value = parseInt(sessionStorage.getItem("element-reply-id")); 315 | header_text.innerText = "Replying to Thread No. " + shortURL(sessionStorage.getItem("element-reply-actor"), sessionStorage.getItem("element-reply-id")); 316 | comment.value = sessionStorage.getItem("element-reply-comment"); 317 | 318 | pos1 = parseInt(sessionStorage.getItem("pos1")); 319 | pos2 = parseInt(sessionStorage.getItem("pos2")); 320 | pos3 = parseInt(sessionStorage.getItem("pos3")); 321 | pos4 = parseInt(sessionStorage.getItem("pos4")); 322 | 323 | box.setAttribute("style", sessionStorage.getItem("element-reply-style")); 324 | 325 | box.style.top = sessionStorage.getItem("reply-top"); 326 | box.style.left = sessionStorage.getItem("reply-left"); 327 | 328 | if(sessionStorage.getItem("eventhandler") === "true"){ 329 | elmnt = header; 330 | document.onmouseup = closeDragElement; 331 | document.onmousemove = elementDrag; 332 | }else{ 333 | document.onmouseup = null; 334 | document.onmousemove = null; 335 | } 336 | } 337 | }; 338 | 339 | document.addEventListener("DOMContentLoaded", stateLoadHandler, false); 340 | 341 | function stripTransferProtocol(value){ 342 | var re = /(https:\/\/|http:\/\/)?(www.)?/; 343 | return value.replace(re, ""); 344 | } 345 | -------------------------------------------------------------------------------- /views/js/themes.js: -------------------------------------------------------------------------------- 1 | function setCookie(key, value, age) { 2 | document.cookie = key + "=" + encodeURIComponent(value) + ";sameSite=strict;max-age=" + (60 * 60 * 24 * age) + ";path=/"; 3 | } 4 | 5 | function getCookie(key) { 6 | if (document.cookie.length != 0) { 7 | return document.cookie.split('; ').find(row => row.startsWith(key)).split('=')[1]; 8 | } 9 | return ""; 10 | } 11 | 12 | function setTheme(name) { 13 | for (let i = 0, tags = document.getElementsByTagName("link"); i < tags.length; i++) { 14 | if (tags[i].type === "text/css" && tags[i].title) { 15 | tags[i].disabled = !(tags[i].title === name); 16 | } 17 | } 18 | 19 | setCookie("theme", name, 3650); 20 | } 21 | 22 | function applyTheme() { 23 | // HACK: disable all of the themes first. this for some reason makes things work. 24 | for (let i = 0, tags = document.getElementsByTagName("link"); i < tags.length; i++) { 25 | if (tags[i].type === "text/css" && tags[i].title) { 26 | tags[i].disabled = true; 27 | } 28 | } 29 | let theme = getCookie("theme") || "default"; 30 | setTheme(theme); 31 | 32 | // reflect this in the switcher 33 | let switcher = document.getElementById("themeSwitcher"); 34 | for(var i = 0; i < switcher.options.length; i++) { 35 | if (switcher.options[i].value === theme) { 36 | switcher.selectedIndex = i; 37 | break; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /views/js/timer.js: -------------------------------------------------------------------------------- 1 | var timerCount; 2 | var timerToggle = false; 3 | var timer; 4 | const contentLoadHandler = function(event){ 5 | timerToggle = !!document.getElementById("autoreload-checkbox").checked; 6 | if(timerToggle){ 7 | timerCount = 45; 8 | document.getElementById("autoreload-countdown").innerHTML = "45"; 9 | document.getElementById("autoreload-countdown").style.visibility = "visible"; 10 | timer = setInterval(timerFunction, 1000); 11 | document.removeEventListener("DOMContentLoaded", contentLoadHandler, false); 12 | } 13 | }; 14 | 15 | document.addEventListener("DOMContentLoaded", contentLoadHandler, false); 16 | 17 | function timerFunction(){ 18 | timerCount--; 19 | document.getElementById("autoreload-countdown").innerHTML = timerCount; 20 | if(timerCount <= 0){ 21 | document.getElementById("autoreload-countdown").innerHTML = "Refreshing..."; 22 | clearInterval(timer); 23 | location.reload(); 24 | } 25 | } 26 | 27 | function autoTimer(){ 28 | timerToggle = !timerToggle; 29 | if(timerToggle === true){ 30 | timerCount = 45; 31 | document.getElementById("autoreload-countdown").innerHTML = "45"; 32 | document.getElementById("autoreload-countdown").style.visibility = "visible"; 33 | timer = setInterval(timerFunction, 1000); 34 | }else{ 35 | clearInterval(timer); 36 | document.getElementById("autoreload-countdown").style.visibility = "hidden"; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /views/layouts/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ .page.Title }} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {{ if not (eq .page.Meta.Preview "") }} 24 | 25 | 26 | {{ end }} 27 | 28 | 29 | 30 | {{ if gt (len .page.ThemeCookie) 0 }} 31 | 32 | {{ else }} 33 | 34 | {{ end }} 35 | {{ range .page.Themes }} 36 | 37 | {{ end }} 38 | 39 | 40 |
41 | 56 | {{ if .page.Board.ModCred }} 57 | {{ if or (eq .page.Board.ModCred .page.Board.Domain) (eq .page.Board.ModCred .page.Board.Actor.Id) }} 58 | [Manage Board] 59 | {{ end }} 60 | {{ end }} 61 |
62 | {{ embed }} 63 | 64 | 65 | -------------------------------------------------------------------------------- /views/locked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FChannel0/FChannel-Server/301c160caf31d23ec53754df71c779c552bde891/views/locked.png -------------------------------------------------------------------------------- /views/manage.html: -------------------------------------------------------------------------------- 1 |
2 |

Manage /{{ .page.Board.Name }}/

3 |
4 | 5 |
6 | 7 |
8 | 9 | 19 |
20 | [Return] 21 | {{ $actor := .page.Board.Actor.Id }} 22 | {{ $board := .page.Board }} 23 | {{ $key := .page.Key }} 24 | {{ if .page.IsLocal }} 25 |
26 |

Following

27 | [{{ if .page.AutoSubscribe }}Toggle Auto Follow Off{{ else }}Toggle Auto Follow On{{ end }}] 28 |
29 | 30 |
31 | 32 |
33 |
also https://fchan.xyz/g/following or https://fchan.xyz/g/followers
34 | 39 |
40 | 41 |
42 |

Followers

43 | 48 |
49 | {{ end }} 50 | 51 |
52 |

Reported

53 | 71 |
72 | 73 | {{ if eq .page.Board.ModCred "admin" }} 74 |
75 |

Janitor Managment

76 |
77 | 78 |
79 | 80 |
81 | 86 |
87 | {{ end }} 88 | 89 | {{ template "partials/footer" .page }} 90 | {{ template "partials/general_scripts" .page }} 91 | -------------------------------------------------------------------------------- /views/news.html: -------------------------------------------------------------------------------- 1 |
2 | {{ range .page.NewsItems }} 3 |
4 |

{{unixtoreadable .Time}} - {{.Title}}


{{.Content}}

5 |
6 | {{ end }} 7 |
8 | -------------------------------------------------------------------------------- /views/notfound.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FChannel0/FChannel-Server/301c160caf31d23ec53754df71c779c552bde891/views/notfound.png -------------------------------------------------------------------------------- /views/npost.html: -------------------------------------------------------------------------------- 1 | {{ template "partials/top" .page }} 2 | 3 |
4 | 5 | 10 | 11 |
12 | 13 | {{ template "partials/posts" .page }} 14 | 15 |
16 | 17 | 18 | 19 | 27 | 28 | {{ if gt (len .page.Posts) 0 }} 29 | {{ if eq (index .page.Posts 0).Type "Note" }} 30 | 33 | {{ end }} 34 | 35 | 39 | {{ end }} 40 | 41 |
20 | 26 | 31 | [Post a Reply] 32 | 36 | {{ $replies := (index .page.Posts 0).Replies }} 37 | {{ $replies.TotalItems }} / {{ $replies.TotalImgs }} 38 |
42 | 43 |
44 | 45 | {{ template "partials/bottom" .page }} 46 | {{ template "partials/footer" .page }} 47 | {{ template "partials/general_scripts" .page }} 48 | {{ template "partials/post_scripts" .page }} 49 | 50 | 51 | -------------------------------------------------------------------------------- /views/nposts.html: -------------------------------------------------------------------------------- 1 | {{ template "partials/top" .page }} 2 | 3 | {{ $board := .page.Board }} 4 |
5 | 6 | 10 | 11 | {{ template "partials/posts" .page }} 12 | 13 |
14 | 15 | 16 | 20 | 21 |
22 | {{ if gt .page.TotalPage 0 }} 23 | {{ $totalPage := .page.TotalPage }} 24 | 40 | {{ end }} 41 | 42 | {{ template "partials/bottom" .page }} 43 | {{ template "partials/footer" .page }} 44 | {{ template "partials/general_scripts" .page }} 45 | {{ template "partials/post_scripts" .page }} 46 | -------------------------------------------------------------------------------- /views/onion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FChannel0/FChannel-Server/301c160caf31d23ec53754df71c779c552bde891/views/onion.png -------------------------------------------------------------------------------- /views/partials/bottom.html: -------------------------------------------------------------------------------- 1 | 27 | 28 | 49 | -------------------------------------------------------------------------------- /views/partials/footer.html: -------------------------------------------------------------------------------- 1 |
2 | [Home] [Rules] [FAQ] 3 |

All trademarks and copyrights on this page are owned by their respective parties.

4 |
5 | 6 |
7 |

v0.1.1

8 | Theme: 9 | 14 |
15 | -------------------------------------------------------------------------------- /views/partials/general_scripts.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /views/partials/post_nav.html: -------------------------------------------------------------------------------- 1 | {{ if ne .ReturnTo "catalog" }} 2 |
  • [Catalog]
  • 3 | {{ end }} 4 | {{ if and (ne .ReturnTo "archive") (ne .PostType "reply") (showArchive .Board.Actor) }} 5 |
  • [Archive]
  • 6 | {{ end }} 7 |
  • [Refresh]
  • 8 | -------------------------------------------------------------------------------- /views/partials/post_scripts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /views/partials/posts.html: -------------------------------------------------------------------------------- 1 | {{ $board := .Board }} 2 | {{ $len := len .Posts }} 3 | {{ $page := . }} 4 | {{ range .Posts }} 5 | {{ $thread := . }} 6 | {{ $opId := .Id }} 7 | {{ if eq $board.InReplyTo "" }} 8 |
    9 | {{ end }} 10 |
    11 |
    12 | {{ if eq $board.ModCred $board.Domain $board.Actor.Id }} 13 | [Delete Post] 14 | {{ end }} 15 | {{ if .Attachment }} 16 | {{ if eq $board.ModCred $board.Domain $board.Actor.Id }} 17 | [Ban Media] 18 | [Delete Attachment] 19 | [Mark Sensitive] 20 | [Sticky] 21 | [Lock] 22 | {{ end }} 23 | File: {{ shortImg (index .Attachment 0).Name }} ({{ convertSize (index .Attachment 0).Size }}) 24 | 25 | 26 |
    {{ parseAttachment . false }}
    27 | 49 | {{ end }} 50 | {{ .Name }} 51 | {{ if .AttributedTo }} {{.AttributedTo }} {{ else }} Anonymous {{ end }} 52 | {{ .TripCode }} 53 | {{ .Published | timeToReadableLong }} No. {{ shortURL $board.Actor.Outbox .Id }} {{ if .Sticky }}{{ end }} {{ if .Locked }} {{ end }}{{ if ne .Type "Tombstone" }}[Report]{{ end }} 54 |

    {{ 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 |
    64 |
    65 |
    >>
    66 |
    67 | {{ if eq $board.ModCred $board.Domain $board.Actor.Id }} 68 | [Delete Post] 69 | {{ end }} 70 | {{ if (index .Attachment 0).Id }} 71 | {{ if eq $board.ModCred $board.Domain $board.Actor.Id }} 72 | [Ban Media] 73 | [Delete Attachment] 74 | [Mark Sensitive] 75 | [Sticky] 76 | [Lock] 77 | {{ end }} 78 | File {{ shortImg (index .Attachment 0).Name }} ({{ convertSize (index .Attachment 0).Size }}) 79 | 80 | 81 |
    82 |
    {{ parseAttachment . false }}
    83 | 106 | {{ end }} 107 | {{ .Name }} 108 | {{ if .AttributedTo }} {{.AttributedTo }} {{ else }} Anonymous {{ end }} 109 | {{ .TripCode }} 110 | {{ .Published | timeToReadableLong }} No. {{ shortURL $board.Actor.Outbox .Id }} {{ if ne .Type "Tombstone" }}[Report]{{ end }} 111 | {{ $parentId := .Id }} 112 | {{ if .Replies.OrderedItems }} 113 | {{ range .Replies.OrderedItems }} 114 | {{ parseReplyLink $board.Actor.Id $opId .Id .Content }} 115 | {{ end }} 116 | {{ end }} 117 |

    {{ parseContent $board.Actor $opId .Content $thread .Id $page.PostType }}

    118 |
    119 |
    120 |
    121 | {{ end }} 122 | {{ end }} 123 |
    124 |
    125 | {{ end }} 126 | -------------------------------------------------------------------------------- /views/partials/top.html: -------------------------------------------------------------------------------- 1 |
    2 |

    /{{ .Board.Name }}/ - {{ .Board.PrefName }}

    3 |

    {{ .Board.Summary }}

    4 | {{ $len := len .Posts }} 5 | {{ if eq $len 0 }} 6 | {{ if eq .PostType "new" }} 7 | 8 | {{ end }} 9 |
    10 |
    11 | 12 | 13 | 14 | 15 | 18 | 19 | 20 | 21 | 22 | 23 | {{ if eq .Board.InReplyTo "" }} 24 | 25 | 26 | 27 | 28 | {{ end }} 29 | 30 | 31 | 32 | 33 | 34 | 35 | 37 | 38 | 39 | 40 | 46 | 47 |
    16 | [X] 17 |
    {{ if .Board.InReplyTo }}{{ end }}
    36 |
    Mark sensitive
    41 |
    42 | 43 |
    44 | 45 |
    48 | 49 | 50 | 51 | 52 | 53 | 54 |
    55 |
    56 | 57 | {{ else }} 58 | 59 | {{ if eq (index .Posts 0).Type "Note" }} 60 | {{ if .Board.InReplyTo }} 61 | {{ if eq (index .Posts 0).Locked false }} 62 | 63 | {{ end }} 64 | {{ else }} 65 | 66 | {{ end }} 67 | {{ $len := len .Posts }} 68 |
    69 |
    70 | 71 | 72 | 73 | 74 | 77 | 78 | 79 | 80 | 81 | {{ if eq .Board.InReplyTo "" }} 82 | 83 | 84 | 85 | 86 | {{ end }} 87 | 88 | 89 | 90 | 91 | 92 | 93 | 95 | 96 | 97 | 98 | 104 | 105 |
    75 | [X] 76 |
    {{ if .Board.InReplyTo }}{{ end }}
    94 |
    Mark sensitive
    99 |
    100 | 101 |
    102 | 103 |
    106 | 107 | 108 | 109 | 110 | 111 |
    112 |
    113 | 114 |
    115 | {{ else }} 116 |

    Archived Post

    117 | {{ end }} 118 | {{ end }} 119 | 120 | 129 | -------------------------------------------------------------------------------- /views/pin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FChannel0/FChannel-Server/301c160caf31d23ec53754df71c779c552bde891/views/pin.png -------------------------------------------------------------------------------- /views/report.html: -------------------------------------------------------------------------------- 1 |
    2 |

    /{{ .page.Board.Name }}/ - {{ .page.Board.PrefName }}

    3 |

    {{ .page.Board.Summary }}

    4 |
    5 | 6 |
    7 | [Back] 8 |
    9 |
    Report Post No. {{ shortURL .page.Board.Actor.Outbox .page.Board.InReplyTo }}
    10 |
    11 |
    12 | 13 |
    14 | 15 | 16 | 17 | 18 | 19 | 20 |
    21 |
    22 |
    23 |
    24 |
    25 | 26 |
    27 |
    28 |
    29 |
    30 | 31 | {{ template "partials/footer" .page }} 32 | {{ template "partials/general_scripts" .page }} 33 | -------------------------------------------------------------------------------- /views/rules.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | [Back] 5 |

    Rules and Agreements:

    6 |
      7 |
    1. Do not break any laws of the U.S.A.
    2. 8 |
    3. You must be 18 to post or access this website.
    4. 9 |
    5. Blue boards are restricted to work-safe (SFW) posts only.
    6. 10 |
    11 | 12 | 13 | -------------------------------------------------------------------------------- /views/sensitive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FChannel0/FChannel-Server/301c160caf31d23ec53754df71c779c552bde891/views/sensitive.png -------------------------------------------------------------------------------- /views/verify.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
    8 |
    9 | 10 |
    11 | 12 |
    13 | 14 |
    15 |
    16 | 17 | 18 | -------------------------------------------------------------------------------- /webfinger/util.go: -------------------------------------------------------------------------------- 1 | package webfinger 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/FChannel0/FChannel-Server/activitypub" 10 | "github.com/FChannel0/FChannel-Server/util" 11 | ) 12 | 13 | var Boards []Board 14 | var FollowingBoards []activitypub.ObjectBase 15 | 16 | type Board struct { 17 | Name string 18 | Actor activitypub.Actor 19 | Summary string 20 | PrefName string 21 | InReplyTo string 22 | Location string 23 | To string 24 | RedirectTo string 25 | Captcha string 26 | CaptchaCode string 27 | ModCred string 28 | Domain string 29 | TP string 30 | Restricted bool 31 | Post activitypub.ObjectBase 32 | } 33 | 34 | type BoardSortAsc []Board 35 | 36 | func (a BoardSortAsc) Len() int { return len(a) } 37 | func (a BoardSortAsc) Less(i, j int) bool { return a[i].Name < a[j].Name } 38 | func (a BoardSortAsc) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 39 | 40 | func GetActorByNameFromBoardCollection(name string) activitypub.Actor { 41 | var actor activitypub.Actor 42 | 43 | boards, _ := GetBoardCollection() 44 | for _, e := range boards { 45 | if e.Actor.Name == name { 46 | actor = e.Actor 47 | } 48 | } 49 | 50 | return actor 51 | } 52 | 53 | func GetBoardCollection() ([]Board, error) { 54 | var collection []Board 55 | 56 | for _, e := range FollowingBoards { 57 | var board Board 58 | 59 | boardActor, err := activitypub.GetActorFromDB(e.Id) 60 | 61 | if err != nil { 62 | return collection, util.MakeError(err, "GetBoardCollection") 63 | } 64 | 65 | if boardActor.Id == "" { 66 | boardActor, err = activitypub.FingerActor(e.Id) 67 | 68 | if err != nil { 69 | return collection, util.MakeError(err, "GetBoardCollection") 70 | } 71 | } 72 | 73 | board.Name = boardActor.Name 74 | board.PrefName = boardActor.PreferredUsername 75 | board.Location = "/" + boardActor.Name 76 | board.Actor = boardActor 77 | board.Restricted = boardActor.Restricted 78 | 79 | collection = append(collection, board) 80 | } 81 | 82 | sort.Sort(BoardSortAsc(collection)) 83 | 84 | return collection, nil 85 | } 86 | 87 | func GetActorFromPath(location string, prefix string) (activitypub.Actor, error) { 88 | var actor string 89 | 90 | pattern := fmt.Sprintf("%s([^/\n]+)(/.+)?", prefix) 91 | re := regexp.MustCompile(pattern) 92 | match := re.FindStringSubmatch(location) 93 | 94 | if len(match) < 1 { 95 | actor = "/" 96 | } else { 97 | actor = strings.Replace(match[1], "/", "", -1) 98 | } 99 | 100 | if actor == "/" || actor == "outbox" || actor == "inbox" || actor == "following" || actor == "followers" { 101 | actor = "main" 102 | } 103 | 104 | var nActor activitypub.Actor 105 | 106 | nActor, err := activitypub.GetActorByNameFromDB(actor) 107 | 108 | if err != nil { 109 | return nActor, util.MakeError(err, "GetActorFromPath") 110 | } 111 | 112 | if nActor.Id == "" { 113 | nActor = GetActorByNameFromBoardCollection(actor) 114 | } 115 | 116 | return nActor, nil 117 | } 118 | 119 | func StartupArchive() error { 120 | for _, e := range FollowingBoards { 121 | actor, err := activitypub.GetActorFromDB(e.Id) 122 | 123 | if err != nil { 124 | return util.MakeError(err, "StartupArchive") 125 | } 126 | 127 | if err := actor.ArchivePosts(); err != nil { 128 | return util.MakeError(err, "StartupArchive") 129 | } 130 | } 131 | 132 | return nil 133 | } 134 | --------------------------------------------------------------------------------