├── .build.yml ├── .containerignore ├── .env.dist ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── accounts.go ├── app.go ├── assets ├── css │ ├── about.css │ ├── accounts.css │ ├── article.css │ ├── content.css │ ├── error.css │ ├── footer.css │ ├── header.css │ ├── inline.css │ ├── listing.css │ ├── login.css │ ├── main.css │ ├── moderate.css │ ├── moderation.css │ ├── reset.css │ ├── s.css │ ├── simple.css │ ├── threaded.css │ ├── user-message.css │ └── user.css ├── favicon.ico ├── icons.svg ├── js │ ├── base.js │ └── main.js └── robots.txt ├── bin └── .git-keep ├── cmd └── brutalinks │ └── main.go ├── content.go ├── content_test.go ├── converter.go ├── cursor.go ├── doc ├── INSTALL.md ├── c2s.md └── todo.md ├── fedbox.go ├── fedbox_test.go ├── federated.go ├── filters.go ├── follows.go ├── frontend.go ├── go.mod ├── handlers.go ├── hashes.go ├── hashes_test.go ├── hotscore.go ├── images ├── Makefile ├── bootstrap │ ├── .dockerignore │ ├── Dockerfile │ ├── bootstrap.sh │ ├── clientadd.sh │ └── useradd.sh ├── build.sh ├── gen-certs.sh └── image.sh ├── internal ├── assets │ ├── assets_dev.go │ ├── assets_prod.go │ └── cmd │ │ └── minify.go └── config │ ├── config.go │ └── env.go ├── items.go ├── loader.go ├── middlewares.go ├── models.go ├── moderation.go ├── repository.go ├── repository_cache.go ├── repository_cache_test.go ├── routes.go ├── sessions.go ├── tags.go ├── templates ├── 404.html ├── about.html ├── content.html ├── error.html ├── layout.html ├── listing.html ├── login.html ├── moderate.html ├── moderation.html ├── new.html ├── partials │ ├── account.html │ ├── account │ │ ├── data.html │ │ ├── meta.html │ │ └── score.html │ ├── admin │ │ └── menu.html │ ├── content │ │ └── edit.html │ ├── flash.html │ ├── follow.html │ ├── follow │ │ ├── data.html │ │ ├── meta.html │ │ └── score.html │ ├── footer.html │ ├── head.html │ ├── header.html │ ├── instance │ │ ├── follow-instance.html │ │ └── sysop.html │ ├── item.html │ ├── item │ │ ├── data.html │ │ ├── domain.html │ │ ├── meta.html │ │ ├── recipients.html │ │ ├── score.html │ │ ├── text.html │ │ └── title.html │ ├── list-item.html │ ├── login │ │ ├── local-login.html │ │ └── remote-login.html │ ├── moderation.html │ ├── moderation │ │ ├── data.html │ │ ├── meta.html │ │ └── score.html │ ├── register │ │ └── new-account.html │ └── user │ │ ├── info.html │ │ └── invite.html ├── register.html ├── user-message.html └── user.html ├── tests ├── Makefile ├── README.md ├── mocks │ ├── Caddyfile │ ├── brutalinks │ │ └── env │ └── fedbox │ │ ├── env │ │ └── fs │ │ └── test │ │ ├── fedbox │ │ ├── __meta_data │ │ ├── __raw │ │ ├── activities │ │ │ ├── 04fb1d85-1ba8-4485-b5ba-3350c0a3b348 │ │ │ │ └── __raw │ │ │ ├── 1ffe04a9-ffc4-4518-9949-2d4f4c1519c9 │ │ │ │ └── __raw │ │ │ ├── 427d876f-5edd-4a87-9927-f06640424590 │ │ │ │ └── __raw │ │ │ ├── 5322fc4a-56dd-4a1b-902e-12aa1182a1b4 │ │ │ │ └── __raw │ │ │ ├── 53ad3db1-1826-4169-8c03-49c6366a2cae │ │ │ │ └── __raw │ │ │ ├── 8aec0ec3-bd93-4020-ab81-a6957674d10d │ │ │ │ └── __raw │ │ │ ├── __raw │ │ │ ├── a4131607-7cd6-4b78-a893-b978d265ac7a │ │ │ │ └── __raw │ │ │ ├── b5bdf696-a9dd-4f8e-b913-9692bc10a471 │ │ │ │ └── __raw │ │ │ ├── bb56def4-ddaf-4de1-8df3-a8bab43bbd36 │ │ │ │ └── __raw │ │ │ ├── c315c40c-e5c4-4410-ad85-da6ac862bf6b │ │ │ │ └── __raw │ │ │ └── d2b41694-2207-4e0b-acf8-6cb3bef219e9 │ │ │ │ └── __raw │ │ ├── actors │ │ │ ├── 03bdf89a-9c59-481c-865c-be0316d810e7 │ │ │ │ ├── __meta_data │ │ │ │ ├── __raw │ │ │ │ ├── liked │ │ │ │ │ ├── 0a99b75d-05a0-439e-9c09-d04a33b78de8 │ │ │ │ │ ├── 2a6ffab9-39be-4ae6-ae1b-aa625fd0fb70 │ │ │ │ │ ├── 977897cf-ba11-414c-ac4d-de1cf3c6dfe5 │ │ │ │ │ ├── __raw │ │ │ │ │ └── a0f5c056-0671-4e87-846b-56577658c79f │ │ │ │ └── outbox │ │ │ │ │ ├── 04fb1d85-1ba8-4485-b5ba-3350c0a3b348 │ │ │ │ │ ├── 1ffe04a9-ffc4-4518-9949-2d4f4c1519c9 │ │ │ │ │ ├── 2a90a2d5-8ece-4e1d-a86a-72d6b986af9e │ │ │ │ │ └── __raw │ │ │ │ │ ├── 5322fc4a-56dd-4a1b-902e-12aa1182a1b4 │ │ │ │ │ ├── 53ad3db1-1826-4169-8c03-49c6366a2cae │ │ │ │ │ ├── 8aec0ec3-bd93-4020-ab81-a6957674d10d │ │ │ │ │ ├── __raw │ │ │ │ │ ├── b5bdf696-a9dd-4f8e-b913-9692bc10a471 │ │ │ │ │ ├── bb56def4-ddaf-4de1-8df3-a8bab43bbd36 │ │ │ │ │ ├── c315c40c-e5c4-4410-ad85-da6ac862bf6b │ │ │ │ │ ├── d2b41694-2207-4e0b-acf8-6cb3bef219e9 │ │ │ │ │ └── fd5a9423-5565-4d8f-aab0-432daaf834c8 │ │ │ │ │ └── __raw │ │ │ ├── __raw │ │ │ └── c4cdfe54-9919-4dd4-8a71-63beafe12b8c │ │ │ │ ├── __meta_data │ │ │ │ ├── __raw │ │ │ │ ├── inbox │ │ │ │ ├── 04fb1d85-1ba8-4485-b5ba-3350c0a3b348 │ │ │ │ ├── 1ffe04a9-ffc4-4518-9949-2d4f4c1519c9 │ │ │ │ ├── 2a90a2d5-8ece-4e1d-a86a-72d6b986af9e │ │ │ │ ├── 5322fc4a-56dd-4a1b-902e-12aa1182a1b4 │ │ │ │ ├── 53ad3db1-1826-4169-8c03-49c6366a2cae │ │ │ │ ├── 8aec0ec3-bd93-4020-ab81-a6957674d10d │ │ │ │ ├── __raw │ │ │ │ ├── b5bdf696-a9dd-4f8e-b913-9692bc10a471 │ │ │ │ ├── bb56def4-ddaf-4de1-8df3-a8bab43bbd36 │ │ │ │ ├── c315c40c-e5c4-4410-ad85-da6ac862bf6b │ │ │ │ ├── d2b41694-2207-4e0b-acf8-6cb3bef219e9 │ │ │ │ └── fd5a9423-5565-4d8f-aab0-432daaf834c8 │ │ │ │ └── outbox │ │ │ │ ├── 427d876f-5edd-4a87-9927-f06640424590 │ │ │ │ └── __raw │ │ ├── inbox │ │ │ ├── 04fb1d85-1ba8-4485-b5ba-3350c0a3b348 │ │ │ ├── 1ffe04a9-ffc4-4518-9949-2d4f4c1519c9 │ │ │ ├── 2a90a2d5-8ece-4e1d-a86a-72d6b986af9e │ │ │ ├── 427d876f-5edd-4a87-9927-f06640424590 │ │ │ ├── 5322fc4a-56dd-4a1b-902e-12aa1182a1b4 │ │ │ ├── 53ad3db1-1826-4169-8c03-49c6366a2cae │ │ │ ├── 8aec0ec3-bd93-4020-ab81-a6957674d10d │ │ │ ├── __raw │ │ │ ├── a4131607-7cd6-4b78-a893-b978d265ac7a │ │ │ ├── b5bdf696-a9dd-4f8e-b913-9692bc10a471 │ │ │ ├── bb56def4-ddaf-4de1-8df3-a8bab43bbd36 │ │ │ ├── c315c40c-e5c4-4410-ad85-da6ac862bf6b │ │ │ ├── d2b41694-2207-4e0b-acf8-6cb3bef219e9 │ │ │ └── fd5a9423-5565-4d8f-aab0-432daaf834c8 │ │ ├── objects │ │ │ ├── 0a99b75d-05a0-439e-9c09-d04a33b78de8 │ │ │ │ ├── __raw │ │ │ │ └── likes │ │ │ │ │ ├── __raw │ │ │ │ │ └── bb56def4-ddaf-4de1-8df3-a8bab43bbd36 │ │ │ ├── 1b1e6889-5081-489c-8a67-d103f504b90b │ │ │ │ └── __raw │ │ │ ├── 2a6ffab9-39be-4ae6-ae1b-aa625fd0fb70 │ │ │ │ ├── __raw │ │ │ │ └── likes │ │ │ │ │ ├── 53ad3db1-1826-4169-8c03-49c6366a2cae │ │ │ │ │ └── __raw │ │ │ ├── 7c4202a4-78cf-44fa-be2a-0544da4968ea │ │ │ │ └── __raw │ │ │ ├── 8ce864d2-deae-4b79-9537-2ed283ee4148 │ │ │ │ └── __raw │ │ │ ├── 977897cf-ba11-414c-ac4d-de1cf3c6dfe5 │ │ │ │ ├── __raw │ │ │ │ ├── likes │ │ │ │ │ ├── 8aec0ec3-bd93-4020-ab81-a6957674d10d │ │ │ │ │ └── __raw │ │ │ │ └── replies │ │ │ │ │ ├── 0a99b75d-05a0-439e-9c09-d04a33b78de8 │ │ │ │ │ ├── 2a6ffab9-39be-4ae6-ae1b-aa625fd0fb70 │ │ │ │ │ ├── __raw │ │ │ │ │ └── a0f5c056-0671-4e87-846b-56577658c79f │ │ │ ├── 9f252ada-b25c-418e-a8d4-1efa21cb0361 │ │ │ │ ├── __raw │ │ │ │ ├── likes │ │ │ │ │ └── __raw │ │ │ │ ├── replies │ │ │ │ │ └── __raw │ │ │ │ └── shares │ │ │ │ │ └── __raw │ │ │ ├── __raw │ │ │ ├── a0f5c056-0671-4e87-846b-56577658c79f │ │ │ │ ├── __raw │ │ │ │ ├── likes │ │ │ │ │ ├── 04fb1d85-1ba8-4485-b5ba-3350c0a3b348 │ │ │ │ │ └── __raw │ │ │ │ └── replies │ │ │ │ │ ├── 0a99b75d-05a0-439e-9c09-d04a33b78de8 │ │ │ │ │ ├── 2a6ffab9-39be-4ae6-ae1b-aa625fd0fb70 │ │ │ │ │ └── __raw │ │ │ ├── a527f654-aeec-4249-92fc-7cff563ffe2c │ │ │ │ └── __raw │ │ │ └── e247130c-e18f-43dd-8655-5c6efbbc46c8 │ │ │ │ ├── __raw │ │ │ │ ├── likes │ │ │ │ └── __raw │ │ │ │ ├── replies │ │ │ │ └── __raw │ │ │ │ └── shares │ │ │ │ └── __raw │ │ └── outbox │ │ │ ├── __raw │ │ │ └── a4131607-7cd6-4b78-a893-b978d265ac7a │ │ └── oauth │ │ ├── .gitignore │ │ └── clients │ │ └── fedbox │ │ └── actors │ │ └── c4cdfe54-9919-4dd4-8a71-63beafe12b8c │ │ └── __raw ├── run-pods.sh ├── script.js └── slam.js ├── view.go ├── votes.go └── webfinger.go /.build.yml: -------------------------------------------------------------------------------- 1 | image: archlinux 2 | packages: 3 | - go 4 | - podman 5 | - buildah 6 | - passt 7 | - aardvark-dns 8 | sources: 9 | - https://git.sr.ht/~mariusor/brutalinks 10 | secrets: 11 | - 32610757-76e9-4671-adf1-98163ca8b594 12 | - 3f30fd61-e33d-4198-aafb-0ff341e9db1c 13 | - 3dcea276-38d6-4a7e-85e5-20cbc903e1ea 14 | tasks: 15 | - build: | 16 | test ${BUILD_SUBMITTER} != "git.sr.ht" && complete-build 17 | set +x 18 | cd brutalinks 19 | make all 20 | - tests: | 21 | test ${BUILD_SUBMITTER} != "git.sr.ht" && complete-build 22 | set -a 23 | source ~/.env.test 24 | cd brutalinks 25 | make test 26 | - coverage: | 27 | test ${BUILD_SUBMITTER} != "git.sr.ht" && complete-build 28 | set -a +x 29 | cd brutalinks && make coverage 30 | - push_to_github: | 31 | test ${BUILD_SUBMITTER} != "git.sr.ht" && complete-build 32 | set -a +x 33 | ssh-keyscan -H github.com >> ~/.ssh/known_hosts 34 | 35 | cd brutalinks 36 | git remote add hub git@github.com:mariusor/go-littr 37 | git push hub --force --all 38 | - image: | 39 | test ${BUILD_SUBMITTER} != "git.sr.ht" && complete-build 40 | set -a +x 41 | source ~/.buildah.env 42 | 43 | _user=$(id -un) 44 | 45 | echo 'unqualified-search-registries = ["docker.io"]' | sudo tee /etc/containers/registries.conf.d/unq-search.conf 46 | echo "${_user}:10000:65536" | sudo tee /etc/subuid 47 | echo "${_user}:10000:65536" | sudo tee /etc/subgid 48 | podman system migrate 49 | 50 | podman login -u="${BUILDAH_USER}" -p="${BUILDAH_SECRET}" quay.io 51 | 52 | cd brutalinks || exit 53 | 54 | _sha=$(git rev-parse --short HEAD) 55 | _branch=$(git branch --points-at=${_sha} | tail -n1 | tr -d '* ') 56 | _version=$(printf "%s-%s" "${_branch}" "${_sha}") 57 | 58 | make -C images cert builder 59 | 60 | make -C images ENV=dev VERSION="${_version}" push 61 | if [ "${_branch}" = "master" ]; then 62 | make -C images ENV=qa VERSION="${_version}" push 63 | fi 64 | _tag=$(git describe --long --tags || true) 65 | if [ -n "${_tag}" ]; then 66 | make -C images ENV=prod VERSION="${_tag}" push 67 | fi 68 | - integration: | 69 | test ${BUILD_SUBMITTER} != "git.sr.ht" && complete-build 70 | set -a +x 71 | source ~/.env.test 72 | set +a -xe 73 | 74 | cd brutalinks 75 | make IMAGE=quay.io/go-ap/brutalinks:qa \ 76 | AUTH_IMAGE=quay.io/go-ap/auth:qa \ 77 | FEDBOX_IMAGE=quay.io/go-ap/fedbox:qa-fs \ 78 | integration 79 | 80 | _status=$? 81 | if [ $_status != 0 ]; then 82 | podman logs -tn --tail=100 tests_brutalinks tests_fedbox tests_auth && exit $_status 83 | fi 84 | 85 | -------------------------------------------------------------------------------- /.containerignore: -------------------------------------------------------------------------------- 1 | **/.git/ 2 | **/.idea/ 3 | **/bin/ 4 | **/tests/ 5 | -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | # HOSTNAME is used as the base for the absolute URLs in the site 2 | HOSTNAME=littr.git 3 | 4 | # NAME is the name that will be displayed in the header of the site 5 | NAME=Littr (dev) 6 | 7 | # LISTEN_PORT is the port number that the application will listen on for connections 8 | LISTEN_PORT=3000 9 | 10 | # LISTEN_HOSTNAME is the host/ip that the application will listen on for connections 11 | LISTEN_HOSTNAME=localhost 12 | 13 | # ENV the environment type sets different configuration settings, valid are: DEV, QA, STAGING, PROD 14 | ENV=dev 15 | 16 | # API_URL is the url of the fedbox instance that provides our C2S ActivityPub API 17 | API_URL=http://fedbox.git 18 | 19 | # SESS_AUTH_KEY is used for encrypting the session data 20 | SESS_AUTH_KEY=16_chars_enc_key= 21 | 22 | # SESS_ENC_KEY 23 | SESS_ENC_KEY=16_chars_enc_key+ 24 | 25 | # OAUTH2_KEY the OAuth2 key used by the application to connect to FedBOX 26 | # it represents the UUID of the generated Application Actor 27 | # eg: https://fedbox.example.com/actors/4f449c81-1dbb-dead-beef-5a83926a0fbf 28 | OAUTH2_KEY=4f449c81-1dbb-dead-beef-5a83926a0fbf 29 | 30 | # OAUTH2_SECRET the OAuth2 secret used by the application to authenticate to FedBOX 31 | OAUTH2_SECRET= 32 | 33 | # SESSIONS_BACKEND the backend to use for session storage, valid: cookie, fs 34 | SESSIONS_BACKEND=fs 35 | 36 | # SESSIONS_PATH if the sessions backend is the file system, we can specify the path where to save session data 37 | SESSIONS_PATH=/tmp 38 | 39 | # ADMIN_CONTACT specifies which admin contact should be displayed in the WebFinger replies 40 | ADMIN_CONTACT=@mariusor@metalhead.club 41 | 42 | # DISABLE_SESSIONS setting this to true, makes the instance essentially read only, by disallowing user logins 43 | DISABLE_SESSIONS=false 44 | 45 | # DISABLE_DOWNVOTING disables allowing Dislike activities 46 | DISABLE_DOWNVOTING=false 47 | 48 | # DISABLE_VOTING disables all Like/Dislike activities 49 | DISABLE_VOTING=false 50 | 51 | # DISABLE_PUBLIC_VOTING disables having Like/Dislike activities visible to actors 52 | DISABLE_PUBLIC_VOTING=false 53 | 54 | # DISABLE_USER_CREATION disables registering users using the /register page 55 | DISABLE_USER_CREATION=false 56 | 57 | # DISABLE_USER_INVITES disables allowing users to create and send invites 58 | DISABLE_USER_INVITES=false 59 | 60 | # DISABLE_ANONYMOUS_COMMENTING specifies if non logged users can submit comments 61 | DISABLE_ANONYMOUS_COMMENTING=false 62 | 63 | # DISABLE_USER_FOLLOWING specifies if the following mechanism should be disabled 64 | DISABLE_USER_FOLLOWING=false 65 | 66 | # DISABLE_MODERATION specifies if the block/ignore/report mechanisms should be disabled 67 | DISABLE_MODERATION=false 68 | 69 | # DISABLE_CACHING specifies if the FedBOX client should cache the values it loads for collections and objects 70 | DISABLE_CACHING=false 71 | 72 | # AUTO_ACCEPT_FOLLOWS specifies if the server should accept automatically Follows from other servers or users 73 | AUTO_ACCEPT_FOLLOWS=false 74 | 75 | # MAINTENANCE_MODE set to true if server needs to be set to maintenance mode. The same can be achieved by sending SIGUSR1 76 | MAINTENANCE_MODE=false 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.prod 3 | .env.qa 4 | .env.dev 5 | .idea/ 6 | *.orig 7 | go.sum 8 | bin/* 9 | *.pem 10 | *.crt 11 | *.key 12 | *.tar* 13 | tests/db 14 | internal/assets/*.gen.go 15 | docker/Caddyfile 16 | .cache 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Marius Orcsik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := bash 2 | .ONESHELL: 3 | .SHELLFLAGS := -eu -o pipefail -c 4 | .DELETE_ON_ERROR: 5 | MAKEFLAGS += --warn-undefined-variables 6 | MAKEFLAGS += --no-builtin-rules 7 | 8 | PROJECT_NAME := brutalinks 9 | ENV ?= dev 10 | 11 | LDFLAGS ?= -X main.version=$(VERSION) 12 | BUILDFLAGS ?= -a -ldflags '$(LDFLAGS)' 13 | TEST_FLAGS ?= -count=1 14 | 15 | UPX = upx 16 | GO ?= go 17 | APPSOURCES := $(wildcard ./*.go internal/*/*.go cmd/brutalinks/*.go) 18 | ASSETFILES := $(wildcard assets/*/* assets/*) 19 | 20 | export CGO_ENABLED=0 21 | 22 | ifneq ($(ENV), dev) 23 | LDFLAGS += -s -w -extldflags "-static" 24 | BUILDFLAGS += -trimpath 25 | endif 26 | 27 | ifeq ($(shell git describe --always > /dev/null 2>&1 ; echo $$?), 0) 28 | BRANCH=$(shell git rev-parse --abbrev-ref HEAD | tr '/' '-') 29 | HASH=$(shell git rev-parse --short HEAD) 30 | VERSION ?= $(shell printf "%s-%s" "$(BRANCH)" "$(HASH)") 31 | endif 32 | ifeq ($(shell git describe --tags > /dev/null 2>&1 ; echo $$?), 0) 33 | VERSION ?= $(shell git describe --tags | tr '/' '-') 34 | endif 35 | 36 | BUILD := $(GO) build $(BUILDFLAGS) 37 | TEST := $(GO) test $(BUILDFLAGS) 38 | 39 | .PHONY: all brutalinks download run clean images test assets help 40 | 41 | .DEFAULT_GOAL := help 42 | 43 | help: ## Help target that shows this message. 44 | @sed -rn 's/^([^:]+):.*[ ]##[ ](.+)/\1:\2/p' $(MAKEFILE_LIST) | column -ts: -l2 45 | 46 | all: brutalinks 47 | 48 | download: go.sum 49 | 50 | go.sum: go.mod 51 | $(GO) mod download all 52 | $(GO) mod tidy 53 | 54 | brutalinks: bin/brutalinks ## Builds the brutalinks binary. 55 | 56 | ifneq ($(ENV),dev) 57 | assets: internal/assets/assets.gen.go 58 | else 59 | assets: ## Builds static javascript and css files for embedding in the production binaries. 60 | endif 61 | 62 | internal/assets/assets.gen.go: $(ASSETFILES) 63 | $(GO) run -tags $(ENV) ./internal/assets/cmd/minify.go -build "prod || qa" -glob assets/*,assets/css/*,assets/js/*,README.md -var AssetFS -o ./internal/assets/assets.gen.go 64 | 65 | bin/brutalinks: go.mod go.sum $(APPSOURCES) assets 66 | $(BUILD) -tags $(ENV) -o $@ ./cmd/brutalinks 67 | ifneq ($(ENV),dev) 68 | $(UPX) -q --mono --no-progress --best $@ || true 69 | endif 70 | 71 | run: ./bin/brutalinks ## Runs the brutalinks binary. 72 | @./bin/brutalinks 73 | 74 | clean: ## Cleanup the build workspace. 75 | -$(RM) bin/* internal/assets/*.gen.go 76 | $(GO) build clean 77 | $(MAKE) -C images $@ 78 | 79 | images: ## Build podman images. 80 | $(MAKE) -C images $@ 81 | 82 | test: TEST_TARGET := . ./internal/... 83 | test: download go.sum ## Run unit tests for the service. 84 | $(TEST) $(TEST_FLAGS) $(TEST_TARGET) 85 | 86 | coverage: TEST_TARGET := . 87 | coverage: TEST_FLAGS += -covermode=count -coverprofile $(PROJECT_NAME).coverprofile 88 | coverage: test ## Run unit tests for the service with coverage. 89 | 90 | integration: download ## Run integration tests for the service. 91 | make ENV=qa -C images builder build 92 | make ENV=qa -C tests pods test-podman 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | This project represents a new attempt at the social link aggregator service. It is modeled after (old)Reddit, HackerNews, and Lobste.rs trying to combine the good parts of these services while mapping them on the foundation of an [ActivityPub](https://www.w3.org/TR/activitypub) generic service called [FedBOX](https://github.com/go-ap/fedbox). 4 | 5 | It targets small to medium communities which ideally focus on a single topic. At the same it allows the community to reach other similar services and the rest of the fediverse ecosystem through the ability to federate. 6 | 7 | The community can be built using an invitation based model, where a user shares the responsibility for moderating the other accounts they invited to the service. The moderation actions are kept public and presented in an anonymized layout. 8 | 9 | Built using a performant stack, and with minimal dependencies, it tries to provide an easy out of the box installation. 10 | 11 | ## Further reading 12 | 13 | More information about BrutaLinks and the other packages in the GoActivityPub library can be found on the [wiki](https://man.sr.ht/~mariusor/go-activitypub/brutalinks/index.md). 14 | 15 | ## Contact and feedback 16 | 17 | If you have problems, questions, ideas or suggestions, please contact us by sending an email to the [mailing list](https://lists.sr.ht/~mariusor/activitypub-go), or on [GitHub](https://github.com/mariusor/brutalinks/issues). If you desire quick feedback, the mailing list is preferred, as the GitHub issues are not checked very often. 18 | 19 | ___ 20 | 21 | [](https://git.sr.ht/~mariusor/brutalinks/blob/master/LICENSE) 22 | [](https://builds.sr.ht/~mariusor/brutalinks) 23 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package brutalinks 2 | 3 | import ( 4 | "crypto/tls" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | "time" 9 | 10 | "git.sr.ht/~mariusor/brutalinks/internal/config" 11 | log "git.sr.ht/~mariusor/lw" 12 | vocab "github.com/go-ap/activitypub" 13 | "github.com/go-ap/errors" 14 | "github.com/go-chi/chi/v5" 15 | "github.com/go-chi/chi/v5/middleware" 16 | "github.com/writeas/go-nodeinfo" 17 | ) 18 | 19 | const ( 20 | // Deleted label 21 | Deleted = "deleted" 22 | // Anonymous label 23 | Anonymous = "anonymous" 24 | // System label 25 | System = "system" 26 | ) 27 | 28 | var ( 29 | // DeletedAccount is a default static value for a deleted account 30 | DeletedAccount = Account{Handle: Anonymous, Hash: AnonymousHash, Metadata: new(AccountMetadata), Pub: &vocab.Tombstone{}} 31 | // AnonymousAccount is a default static value for the anonymous account 32 | AnonymousAccount = Account{Handle: Anonymous, Hash: AnonymousHash, Metadata: new(AccountMetadata)} 33 | // SystemAccount is a default static value for the system account 34 | SystemAccount = Account{Handle: System, Hash: SystemHash, Metadata: new(AccountMetadata)} 35 | // DeletedItem is a default static value for a deleted item 36 | DeletedItem = Item{Title: Deleted, Hash: AnonymousHash, Metadata: new(ItemMetadata), Pub: &vocab.Tombstone{}} 37 | 38 | // cut off date for disallowing interactions with items 39 | oneYearishAgo = time.Now().Add(-12 * 30 * 24 * time.Hour).UTC() 40 | ) 41 | 42 | const ProjectURL = "https://git.sr.ht/~mariusor/brutalinks" 43 | 44 | // Application is the global state of our application 45 | type Application struct { 46 | Version string 47 | BaseURL url.URL 48 | Conf *config.Configuration 49 | ModTags TagCollection 50 | Logger log.Logger 51 | front *handler 52 | Mux *chi.Mux 53 | } 54 | 55 | // Instance is the default instance of our application 56 | var Instance *Application 57 | 58 | func (a Application) Hash() string { 59 | v := strings.TrimSuffix(a.Version, "-git") 60 | if ei := strings.Index(v, "-"); ei > 0 { 61 | v = v[ei+1:] 62 | } 63 | if len(v) > 8 { 64 | v = v[:8] 65 | } 66 | return v 67 | } 68 | 69 | // New instantiates a new Application 70 | func New(c *config.Configuration, l log.Logger, host string, port int, ver string) (*Application, error) { 71 | Instance = &Application{Version: ver} 72 | 73 | if err := Instance.init(c, l, host, port); err != nil { 74 | return nil, err 75 | } 76 | return Instance, nil 77 | } 78 | 79 | func (a *Application) Reload() error { 80 | a.Conf = config.Load(a.Conf.Env, a.Conf.TimeOut) 81 | a.front.storage.cache.remove() 82 | return nil 83 | } 84 | 85 | func (a *Application) init(c *config.Configuration, l log.Logger, host string, port int) error { 86 | a.Conf = c 87 | a.Logger = l 88 | if len(c.HostName) == 0 { 89 | c.HostName = host 90 | } 91 | a.BaseURL = url.URL{Scheme: "http", Host: c.HostName} //fmt.Sprintf("https://%s", c.HostName) 92 | if c.Secure { 93 | a.BaseURL.Scheme = "https" 94 | } 95 | if c.AdminContact == "" { 96 | c.AdminContact = author 97 | } 98 | if host != "" { 99 | c.HostName = host 100 | } 101 | if port != config.DefaultListenPort { 102 | c.ListenPort = port 103 | } 104 | if err := a.Front(); err != nil { 105 | return err 106 | } 107 | a.Routes() 108 | return nil 109 | } 110 | 111 | func (a *Application) Front() error { 112 | conf := appConfig{ 113 | Configuration: *a.Conf, 114 | BaseURL: a.BaseURL.String(), 115 | Logger: a.Logger.New(log.Ctx{"log": "frontend"}), 116 | } 117 | a.front = new(handler) 118 | if err := a.front.init(conf); err != nil { 119 | return err 120 | } 121 | a.ModTags = a.front.storage.modTags 122 | return nil 123 | } 124 | 125 | func (a *Application) Close() error { 126 | return a.front.Close() 127 | } 128 | 129 | func (a *Application) Routes() { 130 | // Routes 131 | r := chi.NewRouter() 132 | r.Use(middleware.RequestID) 133 | if !a.Conf.Env.IsProd() { 134 | r.Use(middleware.Recoverer) 135 | http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} 136 | } 137 | a.Mux = r 138 | // Frontend 139 | r.With(a.front.Repository).Route("/", a.front.Routes(a.Conf)) 140 | 141 | // .well-known 142 | cfg := NodeInfoConfig() 143 | ni := nodeinfo.NewService(cfg, NodeInfoResolverNew(a.front.storage)) 144 | // Web-Finger 145 | r.Route("/.well-known", func(r chi.Router) { 146 | r.Get("/host-meta", a.front.HandleHostMeta) 147 | r.Get("/nodeinfo", ni.NodeInfoDiscover) 148 | r.NotFound(func(w http.ResponseWriter, r *http.Request) { 149 | errors.HandleError(errors.NotFoundf("%s", r.RequestURI)).ServeHTTP(w, r) 150 | }) 151 | }) 152 | r.Get("/nodeinfo", ni.NodeInfo) 153 | r.NotFound(func(w http.ResponseWriter, r *http.Request) { 154 | a.front.v.HandleErrors(w, r, errors.NotFoundf("%s", r.RequestURI)) 155 | }) 156 | r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { 157 | a.front.v.HandleErrors(w, r, errors.MethodNotAllowedf("%s not allowed", r.Method)) 158 | }) 159 | } 160 | 161 | type Cacheable interface { 162 | GetAge() int 163 | } 164 | 165 | type Handler func(http.Handler) http.Handler 166 | type ErrorHandler func(http.ResponseWriter, *http.Request, ...error) 167 | type ErrorHandlerFn func(eh ErrorHandler) Handler 168 | 169 | func Contains[T Renderable](sl []T, it T) bool { 170 | if !it.IsValid() || vocab.IsNil(it.AP()) { 171 | return false 172 | } 173 | itIRI := it.AP().GetLink() 174 | for _, vv := range sl { 175 | if ap := vv.AP(); !vocab.IsNil(ap) && ap.GetLink() == itIRI { 176 | return true 177 | } 178 | } 179 | return false 180 | } 181 | -------------------------------------------------------------------------------- /assets/css/about.css: -------------------------------------------------------------------------------- 1 | header menu { 2 | display: none; 3 | } 4 | article { 5 | max-width: 72vw; 6 | text-align: justify; 7 | opacity: .75; 8 | padding: 0 1rem; 9 | margin-top: 1em; 10 | } 11 | article p:first-of-type { 12 | margin-top: 0; 13 | } 14 | article p { 15 | text-indent: 2.2em; 16 | line-height: 1.5em; 17 | margin-top: .8em; 18 | } 19 | article h1 { 20 | padding: 0 0 .6em 0; 21 | } 22 | article hr { 23 | margin: 1.8em 0; 24 | } 25 | @media (max-width: 576px) { 26 | main article { max-width: unset; } 27 | article h1 { text-align: center; } 28 | } 29 | -------------------------------------------------------------------------------- /assets/css/accounts.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mariusor/brutalinks/b8baedd9faaf6af05b8e3f1608281fe56f695366/assets/css/accounts.css -------------------------------------------------------------------------------- /assets/css/article.css: -------------------------------------------------------------------------------- 1 | /* article.css */ 2 | article { 3 | line-height: 1.3em; 4 | } 5 | article > header > h2 { 6 | display: inline-block; 7 | } 8 | article footer { 9 | opacity: .8; 10 | display: block; 11 | } 12 | article nav dl, article nav ul, article nav ol { 13 | margin-left: 0; 14 | } 15 | article > main q, article > main blockquote { 16 | margin-bottom: .3em; 17 | opacity: .75; 18 | } 19 | article > main blockquote { 20 | border-left: 2px solid var(--main-fg-color); 21 | padding-left: .6em; 22 | } 23 | article > main * + * { 24 | margin-top: .4rem; 25 | margin-bottom: .4rem; 26 | } 27 | article > main h1, article > main h2, article > main h3, 28 | article > main h4, article > main h5, article > main h6 { 29 | font-weight: 600; 30 | } 31 | article > main h1 { 32 | font-size: 1.32em; 33 | line-height: 1.44em; 34 | } 35 | article > main h2 { 36 | font-size: 1.20em; 37 | line-height: 1.32em; 38 | } 39 | article > main h3 { 40 | font-size: 1.11em; 41 | line-height: 1.32em; 42 | } 43 | article > main h4 { 44 | font-size: 1em; 45 | line-height: 1.3em; 46 | } 47 | article > main h5 { 48 | line-height: 1.1em; 49 | font-size: .96em; 50 | } 51 | article > main h6 { 52 | line-height: 1em; 53 | font-size: .94em; 54 | } 55 | article > header h2 { 56 | font-size: 1.24em; 57 | font-weight: 400; 58 | overflow-wrap: break-word; 59 | word-break: break-word; 60 | } 61 | article > header small { 62 | font-size: .75em; 63 | opacity: .8; 64 | margin: 0 0 0 1ex; 65 | padding: 0; 66 | } 67 | article > header small:last-child::before { 68 | content: "("; 69 | } 70 | article > header small:last-child::after { 71 | content: ")"; 72 | } 73 | .data pre { 74 | line-height: 1.3rem; 75 | overflow: auto; 76 | max-width: 100%; 77 | } 78 | .data p:first-of-type { 79 | margin-top: 0; 80 | } 81 | .data p:first-of-type::first-letter { 82 | font-weight: normal; 83 | font-size: 110%; 84 | } 85 | .score .data { 86 | display: grid; 87 | line-height: 1.62em; 88 | } 89 | footer time .icon-clock-o { 90 | width: .85em; 91 | } 92 | footer .icon-activitypub { 93 | width: 1.1em; 94 | display: none; 95 | } 96 | article footer ul, article footer ul li { 97 | display: inline-block; 98 | } 99 | .item footer nav ul li::before { 100 | content: unset; 101 | } 102 | section footer:not(:first-child)::before { 103 | content: '\2012'; 104 | } 105 | .score noscript { display: none; } 106 | small data.score::before { 107 | content: "("; 108 | } 109 | small data.score::after { 110 | content: ")"; 111 | } 112 | article aside svg { 113 | align-self: baseline; 114 | } 115 | article aside { 116 | height: 100%; 117 | align-self: start; 118 | grid-row-start: 1; 119 | grid-row-end: -1; 120 | } 121 | article.item aside.score { 122 | display: grid; 123 | grid-template-rows: 2ex 2ex 1.6ex; 124 | } 125 | .score * { 126 | justify-self: center; 127 | align-self: center; 128 | } 129 | .score a { 130 | display: block; 131 | height: 1em; 132 | width: 1em; 133 | } 134 | .score a:hover { 135 | text-decoration: none; 136 | } 137 | .score data { 138 | font-size: .7em; 139 | } 140 | .score data.inf { 141 | font-weight: bold; 142 | font-size: 1.3em; 143 | margin: 0; 144 | padding: 0; 145 | } 146 | .score data.K, .score data.M, .score data.B { 147 | font-weight: lighter; 148 | font-size: .6em; 149 | } 150 | aside .icon:not(.icon-plus):not(.icon-minus):not(.icon-check) { 151 | opacity: .6; 152 | } 153 | .deleted aside a.ed, aside a[href="#"], aside a[href="#"]:visited { 154 | opacity: .3; 155 | color: var(--main-fg-color); 156 | } 157 | aside a.ed { 158 | color: var(--main-linkvisited-color); 159 | } 160 | aside a.ed:visited { 161 | color: var(--main-linkvisited-color); 162 | } 163 | aside a:visited { 164 | color: var(--main-link-color); 165 | } 166 | summary:focus { 167 | outline: none; 168 | } 169 | .text-plain { 170 | white-space: pre; 171 | font-family: monospace; 172 | /*overflow: auto;*/ 173 | line-height: revert; 174 | } 175 | .text-plain > header { 176 | white-space: normal; 177 | font-family: revert; 178 | } 179 | dl.recipients dt, dl.recipients dd { 180 | opacity: .8; 181 | display: inline; 182 | font-size: .8rem; 183 | } 184 | dl.recipients dd { 185 | margin-left: .2rem; 186 | } 187 | .details-agree { 188 | font-size: .8em; 189 | } 190 | .details-agree input[type=checkbox] { 191 | position: relative; 192 | top: 3px; 193 | } 194 | button svg.icon.icon-reply, button svg.icon.icon-sign-in { 195 | height: .9em; 196 | width: .9em; 197 | } 198 | form fieldset { 199 | border-width: 0; 200 | padding: 0; 201 | margin: 0; 202 | } 203 | -------------------------------------------------------------------------------- /assets/css/content.css: -------------------------------------------------------------------------------- 1 | .icon-reply.h-mirror, .icon-plus.deg-45 { 2 | vertical-align: middle; 3 | } 4 | main.content section span.score { 5 | min-height: 3.5em; 6 | grid-area: sidebar; 7 | align-content: start; 8 | } 9 | main.content section article { 10 | grid-area: main; 11 | align-self: center; 12 | /*margin-left: 1ex;*/ 13 | } 14 | main.content section nav { 15 | grid-area: footer; 16 | margin-left: .4em; 17 | align-self: start; 18 | } 19 | video { 20 | max-width: 90%; 21 | max-height: 32ex; 22 | } 23 | /* 24 | */ 25 | -------------------------------------------------------------------------------- /assets/css/error.css: -------------------------------------------------------------------------------- 1 | main section { 2 | opacity: .65; 3 | margin-top: 1em; 4 | line-height: 2em; 5 | text-align: center; 6 | } 7 | main section h1 { 8 | font-size: 1.2em; 9 | } 10 | main { 11 | font-size: 2em; 12 | max-height: 480px; 13 | margin: 3rem auto; 14 | } 15 | header menu { 16 | display: none; 17 | } 18 | -------------------------------------------------------------------------------- /assets/css/footer.css: -------------------------------------------------------------------------------- 1 | body > footer { 2 | grid-area: footer; 3 | text-align: right; 4 | } 5 | body > footer nav { 6 | margin-top: .5rem; 7 | float: left; 8 | } 9 | body > footer nav:not(.pagination) { 10 | float: right; 11 | } 12 | -------------------------------------------------------------------------------- /assets/css/header.css: -------------------------------------------------------------------------------- 1 | body > header > menu, body > header > ul, body > header > * li { 2 | display: inline-block; 3 | } 4 | body > header { 5 | display: grid; 6 | grid-template-columns: auto auto 5fr; 7 | grid-area: header; 8 | margin: .4rem 0 0 0; 9 | } 10 | body > header .score { 11 | display: inline; 12 | } 13 | body > header h1 a, body > header h1 a:hover, body > header h1 a:visited { 14 | color: var(--main-fg-color); 15 | text-decoration: none; 16 | } 17 | body > header menu.tabs li { 18 | margin-right: .4em; 19 | } 20 | body > header menu.tabs > li::before { 21 | content: unset; 22 | } 23 | body > header svg.icon { 24 | vertical-align: text-bottom; 25 | width: 1.3rem; 26 | height: 1.3rem; 27 | } 28 | body > header svg.icon.icon-activitypub { 29 | width: 1.6rem; 30 | } 31 | body > header svg.icon.icon-star { 32 | width: 1.38rem; 33 | height: 1.38rem; 34 | } 35 | body > header svg.icon.icon-trash-o { 36 | position: relative; 37 | top: -.2rem; 38 | width: 2rem; 39 | height: 2rem; 40 | } 41 | body > header small { 42 | opacity: .7; 43 | font-weight: lighter; 44 | } 45 | body > header menu { 46 | align-self: end; 47 | margin-left: .4rem; 48 | text-align: right; 49 | } 50 | small data.score::before { 51 | content: "("; 52 | } 53 | small data.score::after { 54 | content: ")"; 55 | } 56 | body > header small { 57 | opacity: .7; 58 | font-weight: lighter; 59 | } 60 | body > header h1 a, body > header h1 a:hover, body > header h1 a:visited { 61 | color: var(--main-fg-color); 62 | text-decoration: none; 63 | } 64 | body > footer { 65 | margin-bottom: 1rem; 66 | } 67 | -------------------------------------------------------------------------------- /assets/css/inline.css: -------------------------------------------------------------------------------- 1 | svg.icon, img.icon { height: 1em; width: 1em; fill: currentColor; vertical-align: middle; } 2 | :root { height: 100%; } 3 | :root { --main-bg-color: Window; --main-fg-color: WindowText; --main-link-color: blue; --main-linkvisited-color: rebeccapurple; --main-linkactive-color: red; } 4 | :root.inverted { --main-bg-color: WindowText; --main-fg-color: Window; --main-link-color: dodgerblue; --main-linkvisited-color: mediumpurple; --main-linkactive-color: red; } 5 | @media (prefers-color-scheme: light) { 6 | :root {--main-bg-color: #EFF0F1; --main-fg-color: #232627; --main-link-color: blue; --main-linkvisited-color: rebeccapurple; --main-linkactive-color: red; } 7 | :root.inverted { --main-bg-color: #232627; --main-fg-color: #EFF0F1; --main-link-color: dodgerblue; --main-linkvisited-color: mediumpurple; --main-linkactive-color: red; } 8 | } 9 | @media (prefers-color-scheme: dark) { 10 | :root { --main-bg-color: #232627; --main-fg-color: #EFF0F1; --main-link-color: dodgerblue; --main-linkvisited-color: mediumpurple; --main-linkactive-color: red; } 11 | :root.inverted { --main-bg-color: #EFF0F1; --main-fg-color: #232627; --main-link-color: blue; --main-linkvisited-color: rebeccapurple; --main-linkactive-color: red; } 12 | } 13 | -------------------------------------------------------------------------------- /assets/css/listing.css: -------------------------------------------------------------------------------- 1 | /* listing.css */ 2 | #no-items { 3 | opacity: .65; 4 | margin-top: .6rem; 5 | margin-left: .4rem; 6 | } 7 | article.item, article.account, article.moderation-request, article.follow-request { 8 | display: grid; 9 | justify-items: start; 10 | align-items: baseline; 11 | grid-template-columns: 1.4rem 18fr; 12 | grid-template-rows: minmax(1.4rem, min-content) minmax(0, min-content) minmax(0, min-content); 13 | } 14 | .top-level { 15 | margin-top: .4rem; 16 | } 17 | .top-level > li { 18 | margin-bottom: 1em; 19 | } 20 | body > footer nav.pagination ul li:first-of-type { 21 | } 22 | -------------------------------------------------------------------------------- /assets/css/login.css: -------------------------------------------------------------------------------- 1 | form button { 2 | margin-top: .2em; 3 | } 4 | form fieldset { 5 | border-width: 2px; 6 | } 7 | form input { 8 | padding: .2em; 9 | } 10 | -------------------------------------------------------------------------------- /assets/css/main.css: -------------------------------------------------------------------------------- 1 | /* main.css */ 2 | :root { 3 | font-size: calc(1rem + .15vw); 4 | } 5 | body { 6 | background-color: var(--main-bg-color); 7 | color: var(--main-fg-color); 8 | min-height: 100%; 9 | display: grid; 10 | padding: 0 1rem; 11 | grid-template-rows: [first-line]auto minmax(min-content, 1fr) [last-line]auto; 12 | grid-template-areas: "header" "main" "footer"; 13 | grid-gap: .5rem; 14 | } 15 | ul, ol { 16 | list-style: none; 17 | } 18 | a { 19 | color: var(--main-link-color); 20 | text-decoration: none; 21 | } 22 | a:visited { 23 | color: var(--main-linkvisited-color); 24 | } 25 | a:hover { 26 | text-decoration: underline; 27 | } 28 | a[href="#"], a[href="#"]:visited { 29 | color: var(--main-fg-color); 30 | cursor: text; 31 | text-decoration: none; 32 | } 33 | details > summary { 34 | opacity: .7; 35 | font-size: .9em; 36 | line-height: 1.2em; 37 | list-style-type: '⯈ '; 38 | } 39 | details[open] > summary { 40 | list-style-type: '⯆ '; 41 | } 42 | .deg-45 { 43 | transform: rotate(45deg); 44 | } 45 | .h-mirror { 46 | transform: rotateX(180deg); 47 | } 48 | .v-mirror { 49 | transform: rotateY(180deg); 50 | } 51 | .h-mirror.v-mirror { 52 | transform: rotateY(180deg) rotateX(180deg); 53 | } 54 | svg.icon-adjust, svg.icon-plus, svg.icon-minus, svg.icon-code, svg.icon-angle-double-right,svg.icon-lock, svg.icon-check { 55 | vertical-align: text-top; 56 | } 57 | .icon.icon-home, .icon.icon-star, .icon.icon-activitypub, .icon.icon-adjust, 58 | .icon.icon-lock, .icon.icon-flag, .icon.icon-block, .icon.icon-edit 59 | { 60 | margin-right: -.1em; 61 | } 62 | nav ul, nav ul li, nav dl, nav dl dd, nav dl dt, footer ul, footer ul li { 63 | display: inline-block; 64 | } 65 | .item footer ul li { 66 | margin-left: .2rem; 67 | } 68 | article main ul, article main ol { 69 | list-style: initial; 70 | margin-left: 1.2em; 71 | } 72 | .deleted { 73 | opacity: .7; 74 | } 75 | .icon.icon-lock { 76 | transform: rotateX(180deg); 77 | } 78 | a[rel="mention external"]::before, a[rel="mention"]::before, a[rel="tag"]::before { 79 | opacity: .7; 80 | font-weight: bold; 81 | } 82 | a[rel="mention external"]::before, a[rel="mention"]::before, del.mention::before { 83 | content: "~"; 84 | } 85 | a[rel="mention external"] { 86 | opacity: .8; 87 | } 88 | a[rel="tag"]::before { 89 | content: "#"; 90 | } 91 | footer time { 92 | text-decoration: underline dotted; 93 | } 94 | del.title { 95 | line-height: 2rem; 96 | padding-left: 2ex; 97 | letter-spacing: .3ex; 98 | font-weight: 100; 99 | } 100 | form label { 101 | min-height: 1.8rem; 102 | line-height: 1.8rem; 103 | } 104 | form label.mime-type { 105 | font-size: .8em; 106 | float: right; 107 | } 108 | [popover] { 109 | padding: 20px; 110 | } 111 | [popover]:-internal-popover-in-top-layer::backdrop { 112 | background: rgba(0, 0, 0, .5); 113 | } 114 | dialog { 115 | width: 60%; 116 | z-index: 1; 117 | margin: 4em auto auto auto; 118 | } 119 | dialog button.close { 120 | width: 1.8em; 121 | height: 1.8em; 122 | padding: 0; 123 | font-size: .8em; 124 | margin-top: -1em; 125 | margin-right: -1em; 126 | float: right; 127 | } 128 | img.avatar { 129 | object-fit: cover; 130 | height: 1.4rem; 131 | width: 1.4rem; 132 | margin-right: .3rem; 133 | } 134 | menu li:not(:first-child)::before, nav ul li:not(:first-child)::before { 135 | content: "\22c5"; 136 | margin-right: .2em; 137 | } 138 | nav ul li a { 139 | margin-right: .2em; 140 | } 141 | p { 142 | word-break: break-word; 143 | } 144 | form textarea { 145 | width: 98%; 146 | } 147 | form button { 148 | margin-top: .2em; 149 | padding: .2em; 150 | } 151 | #reply, #new { 152 | max-width: 30rem; 153 | } 154 | -------------------------------------------------------------------------------- /assets/css/moderate.css: -------------------------------------------------------------------------------- 1 | #moderate > details { 2 | display: inline-block; 3 | } 4 | #moderate > details aside, #user details summary { 5 | margin: .2em 0; 6 | } 7 | #moderate > details summary h2, details summary nav { 8 | display: inline-block; 9 | align-self: end; 10 | } 11 | #moderate > details aside nav { 12 | margin-top: .4em; 13 | } 14 | .moderate article section { 15 | grid-area: main; 16 | align-self: center; 17 | margin-left: 1ex; 18 | } 19 | .moderate article { 20 | display: grid; 21 | grid-template-columns: 1.6rem 11fr; 22 | grid-template-areas: "sidebar main"; 23 | } 24 | .moderate article section footer * { 25 | display: inline-block; 26 | } 27 | .moderate article section footer { 28 | opacity: .6; 29 | line-height: 1.12rem; 30 | } 31 | -------------------------------------------------------------------------------- /assets/css/moderation.css: -------------------------------------------------------------------------------- 1 | .moderation details[open] summary { 2 | float: left; 3 | margin-right: .4rem; 4 | } 5 | .moderation details { 6 | font-size: .95em; 7 | clear: both; 8 | margin: .3em 0 .5em 0; 9 | } 10 | .moderation-request + .score, .follow-request + .score, .account + .score { 11 | grid-template-rows: 1.2em 1.5em; 12 | min-height: 2.6em; 13 | } 14 | .moderation-request details { 15 | padding: .2em 0; 16 | margin-top: .2em; 17 | } 18 | .moderation-request details+details { 19 | margin-top: 0; 20 | } 21 | -------------------------------------------------------------------------------- /assets/css/reset.css: -------------------------------------------------------------------------------- 1 | /* Josh's Custom CSS Reset: https://www.joshwcomeau.com/css/custom-css-reset */ 2 | *, *::before, *::after { 3 | box-sizing: border-box; 4 | } 5 | body { 6 | line-height: 1.5; 7 | } 8 | img, picture, video, canvas { 9 | display: block; 10 | max-width: 100%; 11 | } 12 | input, button, textarea, select { 13 | font: inherit; 14 | } 15 | p, h1, h2, h3, h4, h5, h6 { 16 | overflow-wrap: break-word; 17 | } 18 | p { 19 | text-wrap: pretty; 20 | } 21 | h1, h2, h3, h4, h5, h6 { 22 | text-wrap: balance; 23 | } 24 | body, html, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, abbr, 25 | acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, 26 | strike, strong, tt, var, b, u, i, center, a, details, dl, dt, dd, ol, ul, li, table, 27 | caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, 28 | figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, 29 | time, mark, audio, video { 30 | margin: 0; 31 | padding: 0; 32 | border: 0; 33 | vertical-align: baseline; 34 | } 35 | figcaption { 36 | font-size: small; 37 | } 38 | pre, code, samp, kbd { 39 | font-family: monospace; 40 | font-size: 0.9em; 41 | } 42 | pre code, pre samp, pre kbd { 43 | font-size: 1em; 44 | } 45 | pre kbd { } 46 | pre { 47 | padding: 0.5em; 48 | overflow: auto; 49 | } 50 | h1 { font-size: 160%; } 51 | h2 { font-size: 140%; } 52 | h3 { font-size: 125%; } 53 | h4 { font-size: 112%; } 54 | h5 { font-size: 98%; } 55 | h6 { font-size: 95%; } 56 | body { 57 | font-family: sans-serif; 58 | } 59 | -------------------------------------------------------------------------------- /assets/css/s.css: -------------------------------------------------------------------------------- 1 | @media (max-width: 860px) { 2 | a[rel="mention"] { 3 | max-width: 100px; 4 | white-space: nowrap; 5 | overflow: hidden; 6 | text-overflow: ellipsis; 7 | display: inline-block; 8 | vertical-align: bottom; 9 | } 10 | body > header h1 a small, body > header h1 a strong { 11 | display: none; 12 | } 13 | body > header menu.tabs li { 14 | margin: 0 .4em .2em 0; 15 | } 16 | } 17 | @media (max-width: 768px) { 18 | nav:not(.pagination) li::before { 19 | content: unset !important; 20 | } 21 | body > footer menu li:first-of-type { 22 | display: inline-block; 23 | } 24 | body > header h1 a small, body > header menu a span { 25 | display: none; 26 | } 27 | main article > header h2 { 28 | font-size: 1.16em; 29 | } 30 | section footer nav li a:not([rel="bookmark"]), 31 | body > footer nav:not(.pagination) li { 32 | display: none; 33 | } 34 | video { width: 100%; } 35 | } 36 | @media (max-width: 576px) { 37 | body { padding: 0 .4rem; } 38 | h1 { font-size: 140%; } 39 | h2 { font-size: 130%; } 40 | h3 { font-size: 120%; } 41 | h4 { font-size: 100%; } 42 | h5 { font-size: 96%; } 43 | h6 { font-size: 92%; } 44 | section header h2 small, body > header menu a span, body > header h1 a strong, 45 | body > header h1 a small, body > header nav li data { 46 | display: none; 47 | } 48 | body > header { 49 | margin: .4rem 0 .8rem 0; 50 | } 51 | footer { 52 | font-size: .9em; 53 | } 54 | section footer::before { 55 | content: unset; 56 | } 57 | } 58 | @media (max-width: 480px) { 59 | body > header menu li a[rel=mention]::before, 60 | body > header menu:not(.tabs) li::before { 61 | content: unset; 62 | } 63 | body > header menu:not(.tabs) li a, form fieldset { 64 | font-size: .9em; 65 | } 66 | form label { 67 | font-size: .8em; 68 | } 69 | footer time svg, body > header h1 a small { 70 | display: none; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /assets/css/simple.css: -------------------------------------------------------------------------------- 1 | noscript { 2 | display: inline; 3 | } 4 | -------------------------------------------------------------------------------- /assets/css/threaded.css: -------------------------------------------------------------------------------- 1 | .children { 2 | margin-left: .34em; 3 | border-left-width: 1px; 4 | border-left-style: solid; 5 | } 6 | main > .children { 7 | margin-left: unset; 8 | } 9 | li details .children > li { 10 | padding: .5rem 0 .5rem 1rem; 11 | } 12 | main > .children > li { 13 | padding: .5rem 0 .5rem 0; 14 | } 15 | main > .children { 16 | border: unset; 17 | } 18 | main > .children details > summary { 19 | margin-left: unset; 20 | } 21 | details[open] > summary {} 22 | details > summary small { 23 | font-size: .85em; 24 | } 25 | .children details[open] { } 26 | .children details:not([open]) { } 27 | .children details { 28 | margin: .3em 0 0 -.1em; 29 | } 30 | .children details > summary { 31 | margin-left: 1em; 32 | } 33 | .children > li span.score, .top-level > li span.score { 34 | min-height: 3.5em; 35 | grid-area: sidebar; 36 | align-content: start; 37 | } 38 | .children > li article { 39 | margin-left: -.2em; 40 | } 41 | .children > li article, .top-level > li article { 42 | grid-area: main; 43 | align-self: center; 44 | } 45 | .children > li nav, .top-level > li nav { 46 | grid-area: footer; 47 | margin-left: .4em; 48 | align-self: start; 49 | } 50 | .lvl-1 { 51 | border-color: rgb(from var(--main-fg-color) r g b / 0.8); 52 | } 53 | .lvl-2 { 54 | border-color: rgb(from var(--main-fg-color) r g b / 0.6); 55 | } 56 | .lvl-3 { 57 | border-color: rgb(from var(--main-fg-color) r g b / 0.4); 58 | } 59 | .lvl-4 { 60 | border-color: rgb(from var(--main-fg-color) r g b / 0.3); 61 | } 62 | .lvl-5 { 63 | border-color: rgb(from var(--main-fg-color) r g b / 0.2); 64 | } 65 | .lvl-0 { 66 | border-color: rgb(from var(--main-fg-color) r g b / 0.1); 67 | } 68 | -------------------------------------------------------------------------------- /assets/css/user-message.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mariusor/brutalinks/b8baedd9faaf6af05b8e3f1608281fe56f695366/assets/css/user-message.css -------------------------------------------------------------------------------- /assets/css/user.css: -------------------------------------------------------------------------------- 1 | #user > details { 2 | display: inline-block; 3 | } 4 | #user > details aside, #user details summary { 5 | margin: .2em 0; 6 | } 7 | #user > details summary h2, details summary nav { 8 | display: inline-block; 9 | align-self: end; 10 | } 11 | #user > details aside nav { 12 | margin-top: .4em; 13 | } 14 | #invites { 15 | height: 3rem; 16 | } 17 | details#invites summary { 18 | margin-right: .3rem; 19 | margin-top: .3rem; 20 | } 21 | details#invites summary, #invites form { 22 | float: left; 23 | } 24 | .pub-key details[open] { 25 | clear: both; 26 | } 27 | .pub-key details[open] pre { 28 | max-width: 65ex; 29 | font-size: .8em; 30 | right: .5em; 31 | padding: .2em; 32 | text-align: justify; 33 | overflow: hidden; 34 | position: fixed; 35 | z-index: 100; 36 | box-shadow: 0 0 2px -2px var(--main-fg-color); 37 | background-color: var(--main-bg-color); 38 | } 39 | #user details aside ul { 40 | display: inline-block; 41 | } 42 | #user details aside ul li { 43 | display: inline; 44 | padding-right: 0; 45 | } 46 | #user details aside ul li:not(:last-child)::after { 47 | content: "\22c5"; 48 | } 49 | -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mariusor/brutalinks/b8baedd9faaf6af05b8e3f1608281fe56f695366/assets/favicon.ico -------------------------------------------------------------------------------- /assets/js/base.js: -------------------------------------------------------------------------------- 1 | this.Element && function (a) { 2 | a.matchesSelector = a.matchesSelector || a.mozMatchesSelector || a.msMatchesSelector || a.oMatchesSelector || a.webkitMatchesSelector || function (b) { 3 | let c = this, e = (c.parentNode || c.document).querySelectorAll(b), f = -1; 4 | for (; e[++f] && e[f] != c;) ; 5 | return !!e[f] 6 | }, a.matches = a.matches || a.matchesSelector 7 | }(Element.prototype); 8 | this.Element && function (a) { 9 | a.closest = a.closest || function (b) { 10 | let c = this; 11 | for (; c.matches && !c.matches(b);) c = c.parentNode; 12 | return c.matches ? c : null 13 | } 14 | }(Element.prototype); 15 | let addEvent = function (a, b, c) { 16 | a.attachEvent ? a.attachEvent('on' + b, c) : a.addEventListener(b, c) 17 | }; 18 | let removeEvent = function (a, b, c) { 19 | a.detachEvent ? a.detachEvent('on' + b, c) : a.removeEventListener(b, c) 20 | }; 21 | let getCookie = function (a) { 22 | let b = document.cookie.match('(^|;) ?' + a + '=([^;]*)(;|$)'); 23 | return b ? b[2] : null 24 | }; 25 | let setCookie = function (a, b, c = 1e3) { 26 | let e = new Date; 27 | e.setTime(e.getTime() + 86400000 * c), document.cookie = a + '=' + b + ';path=/;expires=' + e.toGMTString() 28 | }; 29 | let deleteCookie = function (a) { 30 | setCookie(a, '', -1) 31 | }; 32 | let OnReady = function (a) { 33 | 'loading' == document.readyState ? document.addEventListener && document.addEventListener('DOMContentLoaded', a) : a.call() 34 | }; 35 | let $ = function (a, b) { 36 | return console.debug(a, b), (b || document).querySelectorAll(a) 37 | }; 38 | -------------------------------------------------------------------------------- /assets/js/main.js: -------------------------------------------------------------------------------- 1 | OnReady( function() { 2 | let isInverted = function () { return getCookie("inverted") == "true" || false; }; 3 | let haveModals = function() { return (typeof document.createElement('dialog').showModal === "function"); }; 4 | 5 | let root = $("html")[0]; 6 | if (isInverted()) { 7 | root.classList.add("inverted"); 8 | } else { 9 | root.classList.remove("inverted"); 10 | } 11 | addEvent($("#invert")[0], "click", function(e) { 12 | if (isInverted()) { 13 | root.classList.remove("inverted"); 14 | deleteCookie("inverted"); 15 | } else { 16 | root.classList.add("inverted"); 17 | setCookie("inverted", true); 18 | } 19 | e.preventDefault(); 20 | e.stopPropagation(); 21 | }); 22 | $(".score a").forEach(function(lnk) { 23 | if(lnk.getAttribute("href") != "#") { return; } 24 | addEvent(lnk, "click", function(e){ 25 | e.stopPropagation(); 26 | e.preventDefault(); 27 | }); 28 | }); 29 | $("a.rm").forEach(function (del) { 30 | addEvent(del, "click", function(e) { 31 | e.stopPropagation(); 32 | e.preventDefault(); 33 | 34 | $(".rm-confirm").forEach(function (conf) { 35 | conf.parentNode && conf.parentNode.removeChild(conf); 36 | }); 37 | 38 | let el = e.target.closest("a"); 39 | let hash = el.getAttribute("data-hash"); 40 | 41 | let yesId = "yes-" + hash; 42 | let noId = "no-" + hash; 43 | 44 | let conf = document.createElement('span'); 45 | conf.classList.add("rm-confirm"); 46 | conf.innerHTML = ': yes / no'; 47 | el.after(conf); 48 | addEvent($("a#" + yesId)[0], "click", function (e) { 49 | window.location = el.getAttribute("href"); 50 | el.parentNode.removeChild(conf); 51 | e.stopPropagation(); 52 | e.preventDefault(); 53 | }); 54 | addEvent($("a#" + noId)[0], "click", function (e) { 55 | el.parentNode.removeChild(conf); 56 | e.stopPropagation(); 57 | e.preventDefault(); 58 | }); 59 | }); 60 | }); 61 | $("button[type='reset']").forEach(function (btn) { 62 | addEvent(btn, "click", function(e) { 63 | let backHref = btn.getAttribute("data-back"); 64 | if (backHref == undefined) { return; } 65 | if (window.location.href.endsWith(backHref)) { return; } 66 | if (backHref.length > 0) { 67 | window.location = backHref; 68 | } else { 69 | history.go(-1); 70 | } 71 | }); 72 | }); 73 | $("button.close").forEach(function (close) { 74 | addEvent(close, "click", function(e) { 75 | e.stopPropagation(); 76 | e.preventDefault(); 77 | let el = e.target.closest("dialog"); 78 | if (haveModals()) { 79 | el.close(); 80 | } else { 81 | el.remove(); 82 | } 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /assets/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | 4 | User-agent: search.marginalia.nu 5 | Allow: / 6 | -------------------------------------------------------------------------------- /bin/.git-keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mariusor/brutalinks/b8baedd9faaf6af05b8e3f1608281fe56f695366/bin/.git-keep -------------------------------------------------------------------------------- /cmd/brutalinks/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "os" 7 | "path/filepath" 8 | "runtime/debug" 9 | "syscall" 10 | "time" 11 | 12 | "git.sr.ht/~mariusor/brutalinks" 13 | "git.sr.ht/~mariusor/brutalinks/internal/config" 14 | log "git.sr.ht/~mariusor/lw" 15 | w "git.sr.ht/~mariusor/wrapper" 16 | "github.com/go-ap/errors" 17 | ) 18 | 19 | var version = "HEAD" 20 | 21 | const defaultPort = config.DefaultListenPort 22 | const defaultTimeout = time.Second * 5 23 | 24 | // Run is the wrapper for starting the web-server and handling signals 25 | func Run(a *brutalinks.Application) error { 26 | ctx, cancelFn := context.WithCancel(context.TODO()) 27 | 28 | setters := []w.SetFn{w.Handler(a.Mux)} 29 | if a.Conf.Secure && len(a.Conf.CertPath) > 0 && len(a.Conf.KeyPath) > 0 { 30 | setters = append(setters, w.WithTLSCert(a.Conf.CertPath, a.Conf.KeyPath)) 31 | } 32 | if a.Conf.ListenHost == "systemd" { 33 | setters = append(setters, w.OnSystemd()) 34 | } else if filepath.IsAbs(a.Conf.ListenHost) { 35 | dir, _ := filepath.Split(a.Conf.ListenHost) 36 | if _, err := os.Stat(dir); err == nil { 37 | setters = append(setters, w.OnSocket(a.Conf.ListenHost)) 38 | defer func() { _ = os.RemoveAll(a.Conf.ListenHost) }() 39 | } 40 | } else { 41 | setters = append(setters, w.OnTCP(a.Conf.Listen())) 42 | } 43 | 44 | srvRun, srvStop := w.HttpServer(setters...) 45 | 46 | l := a.Logger.WithContext(log.Ctx{ 47 | "version": a.Version, 48 | "listenOn": a.Conf.Listen(), 49 | "TLS": a.Conf.Secure, 50 | "host": a.Conf.HostName, 51 | "env": a.Conf.Env, 52 | "timeout": a.Conf.TimeOut, 53 | "cert": a.Conf.CertPath, 54 | "key": a.Conf.KeyPath, 55 | }) 56 | 57 | stopFn := func(ctx context.Context) { 58 | // NOTE(marius): close the storage repository 59 | if err := a.Close(); err != nil { 60 | l.Warnf("Close error: %s", err) 61 | return 62 | } 63 | if err := srvStop(ctx); err != nil { 64 | l.Errorf("Stopped with error: %s", err) 65 | } else { 66 | l.Infof("Stopped") 67 | } 68 | } 69 | 70 | l.Infof("Started") 71 | 72 | defer stopFn(ctx) 73 | // Set up the signal handlers functions so the OS can tell us if it requires us to stop 74 | sigHandlerFns := w.SignalHandlers{ 75 | syscall.SIGHUP: func(_ chan<- error) { 76 | l.Infof("SIGHUP received, reloading configuration") 77 | if err := a.Reload(); err != nil { 78 | l.Errorf("Failed to reload: %s", err.Error()) 79 | } 80 | }, 81 | syscall.SIGUSR1: func(_ chan<- error) { 82 | l.Infof("SIGUSR1 received, switching to maintenance mode") 83 | a.Conf.MaintenanceMode = !a.Conf.MaintenanceMode 84 | }, 85 | syscall.SIGTERM: func(status chan<- error) { 86 | // kill -SIGTERM XXXX 87 | l.Infof("SIGTERM received, stopping") 88 | status <- nil 89 | }, 90 | syscall.SIGINT: func(status chan<- error) { 91 | // kill -SIGINT XXXX or Ctrl+c 92 | l.Infof("SIGINT received, stopping") 93 | status <- nil 94 | }, 95 | syscall.SIGQUIT: func(status chan<- error) { 96 | l.Warnf("SIGQUIT received, force stopping") 97 | cancelFn() 98 | status <- nil 99 | }, 100 | } 101 | 102 | // Wait for OS signals asynchronously 103 | err := w.RegisterSignalHandlers(sigHandlerFns).Exec(ctx, srvRun) 104 | if err != nil { 105 | l.Infof("Shutting down") 106 | } 107 | return err 108 | } 109 | 110 | func main() { 111 | var wait time.Duration 112 | var port int 113 | var host string 114 | var env string 115 | 116 | flag.DurationVar(&wait, "graceful-timeout", defaultTimeout, "the duration for which the server gracefully wait for existing connections to finish - e.g. 15s or 1m") 117 | flag.IntVar(&port, "port", defaultPort, "the port on which we should listen on") 118 | flag.StringVar(&host, "host", "", "the host on which we should listen on") 119 | flag.StringVar(&env, "env", "unknown", "the environment type") 120 | flag.Parse() 121 | 122 | c := config.Load(config.EnvType(env), wait) 123 | l := log.Dev(log.SetLevel(c.LogLevel)) 124 | if c.Env.IsDev() { 125 | errors.IncludeBacktrace = c.Env.IsDev() 126 | } 127 | 128 | if build, ok := debug.ReadBuildInfo(); ok && version == "HEAD" { 129 | if build.Main.Version != "(devel)" { 130 | version = build.Main.Version 131 | } 132 | for _, bs := range build.Settings { 133 | if bs.Key == "vcs.revision" { 134 | version = bs.Value[:8] 135 | } 136 | if bs.Key == "vcs.modified" { 137 | version += "-git" 138 | } 139 | } 140 | } 141 | 142 | c.Version = version 143 | a, err := brutalinks.New(c, l, host, port, version) 144 | if err != nil { 145 | l.Errorf("Failed to start application: %+s", err) 146 | os.Exit(1) 147 | } 148 | if err = Run(a); err != nil { 149 | l.Errorf("Error: %+s", err) 150 | os.Exit(1) 151 | } 152 | os.Exit(0) 153 | } 154 | -------------------------------------------------------------------------------- /cursor.go: -------------------------------------------------------------------------------- 1 | package brutalinks 2 | 3 | import ( 4 | "sort" 5 | "time" 6 | 7 | vocab "github.com/go-ap/activitypub" 8 | ) 9 | 10 | type Cursor struct { 11 | after vocab.IRI 12 | before vocab.IRI 13 | items RenderableList 14 | total uint 15 | } 16 | 17 | var emptyCursor = Cursor{} 18 | 19 | type RenderableList []Renderable 20 | 21 | func (r *RenderableList) Valid() bool { 22 | return r != nil && len(*r) > 0 23 | } 24 | 25 | func (r RenderableList) Items() ItemCollection { 26 | items := make(ItemCollection, 0) 27 | for _, ren := range r { 28 | if it, ok := ren.(*Item); ok { 29 | items = append(items, *it) 30 | } 31 | } 32 | return items 33 | } 34 | 35 | func (r RenderableList) Contains(ren Renderable) bool { 36 | for _, rr := range r { 37 | if rr.Type() == ren.Type() && rr.ID() == ren.ID() { 38 | return true 39 | } 40 | } 41 | return false 42 | } 43 | 44 | func (r RenderableList) Follows() FollowRequests { 45 | follows := make(FollowRequests, 0) 46 | for _, ren := range r { 47 | if it, ok := ren.(*FollowRequest); ok { 48 | follows = append(follows, *it) 49 | } 50 | } 51 | return follows 52 | } 53 | 54 | func (r *RenderableList) Merge(other RenderableList) { 55 | for k, it := range other { 56 | (*r)[k] = it 57 | } 58 | } 59 | 60 | func (r *RenderableList) Append(others ...Renderable) { 61 | for _, o := range others { 62 | *r = append(*r, o) 63 | } 64 | } 65 | 66 | func ByDate(r RenderableList) []Renderable { 67 | rl := make([]Renderable, 0) 68 | for _, rr := range r { 69 | rl = append(rl, rr) 70 | } 71 | sort.SliceStable(rl, func(i, j int) bool { 72 | ri := rl[i] 73 | rj := rl[j] 74 | if ri.Type() == rj.Type() { 75 | switch ri.Type() { 76 | case CommentType: 77 | ii, oki := ri.(*Item) 78 | ij, okj := rj.(*Item) 79 | subOrder := ii.SubmittedAt.After(ij.SubmittedAt) 80 | subSame := ii.SubmittedAt.Sub(ij.SubmittedAt) == 0 81 | updOrder := ii.UpdatedAt.After(ij.UpdatedAt) 82 | return oki && okj && (subOrder || (subSame && updOrder)) 83 | } 84 | } 85 | return ri.Date().After(rj.Date()) 86 | }) 87 | return rl 88 | } 89 | 90 | func ByScore(r RenderableList) []Renderable { 91 | if !Instance.Conf.VotingEnabled { 92 | return ByDate(r) 93 | } 94 | rl := make([]Renderable, 0, len(r)) 95 | for _, rr := range r { 96 | rl = append(rl, rr) 97 | } 98 | sort.SliceStable(rl, func(i, j int) bool { 99 | var hi, hj float64 100 | 101 | ri := rl[i] 102 | ii, oki := ri.(*Item) 103 | 104 | rj := rl[j] 105 | ij, okj := rj.(*Item) 106 | if oki { 107 | hi = Hacker(int64(ii.Votes.Score()), time.Now().Sub(ii.SubmittedAt)) 108 | } 109 | if okj { 110 | hj = Hacker(int64(ij.Votes.Score()), time.Now().Sub(ij.SubmittedAt)) 111 | } 112 | if oki && okj && hi+hj > 0 { 113 | return hi >= hj 114 | } 115 | return ri.Date().After(rj.Date()) 116 | }) 117 | return rl 118 | } 119 | 120 | func lastUpdatedInThread(it Renderable) time.Time { 121 | maxDate := it.Date() 122 | ob, ok := it.(*Item) 123 | if !ok { 124 | return maxDate 125 | } 126 | for _, ic := range *ob.Children() { 127 | if threadLastUpdate := lastUpdatedInThread(ic); threadLastUpdate.After(maxDate) { 128 | maxDate = threadLastUpdate 129 | } 130 | } 131 | return maxDate 132 | } 133 | 134 | func ByRecentActivity(r RenderableList) []Renderable { 135 | rl := make([]Renderable, 0, len(r)) 136 | for _, rr := range r { 137 | rl = append(rl, rr) 138 | } 139 | sort.SliceStable(rl, func(i, j int) bool { 140 | ri := rl[i] 141 | rj := rl[j] 142 | return lastUpdatedInThread(ri).After(lastUpdatedInThread(rj)) 143 | }) 144 | return rl 145 | } 146 | -------------------------------------------------------------------------------- /doc/INSTALL.md: -------------------------------------------------------------------------------- 1 | # Installing 2 | 3 | ## Pre-requisites 4 | 5 | The basic requirement for running [BrutaLinks](https://git.sr./~mariusor/brutalinks) locally is a 6 | go dev environment (version 1.18 or newer). 7 | 8 | ```sh 9 | $ git clone https://git.sr.ht/~mariusor/brutalinks 10 | $ cd brutalinks 11 | ``` 12 | 13 | ### Running Fed::BOX 14 | 15 | We are now using [fedbox](https://github.com/go-ap/fedbox) as an *ActivityPub* backend. 16 | Follow the project's [install instructions]((https://github.com/go-ap/fedbox/blob/master/doc/INSTALL.md)) to get the instance running. We'll assume your instance is https://fedbox.example.com 17 | 18 | After FedBOX is running, you need to create the required brutalinks actors: 19 | 20 | ```sh 21 | # This creates an OAuth2 account and ActivityPub Application Actor for Brutalinks. 22 | $ fedboxctl oauth client add --redirectUri https://brutalinks.example.com/callback 23 | client's pw: 24 | pw again: 25 | Client ID: 4f449c81-1dbb-dead-beef-5a83926a0fbf 26 | 27 | # The Actor can be found at: https://fedbox.example.com/actors/4f449c81-1dbb-dead-beef-5a83926a0fbf 28 | 29 | # We can now create some of the additional objects: 30 | 31 | # First we create tags for the instance operators and moderators: 32 | $ fedboxctl ap add --name "#sysop" --name "#mod" \ 33 | --attributedTo https://fedbox.example.com/actors/4f449c81-1dbb-dead-beef-5a83926a0fbf 34 | 35 | # This creates a Person actor named "admin" with the #sysop tag 36 | $ fedboxctl ap actor add admin --tag "#sysop" 37 | admin's pw: 38 | pw again: 39 | Added "Person" [admin]: https://fedbox.example.com/actors/310a1a7c-dead-beef-d00d-9a6a8e40acdf 40 | 41 | ``` 42 | 43 | ### Editing the configuration 44 | 45 | ```sh 46 | $ cp .env.dist .env 47 | $ $EDITOR .env 48 | ``` 49 | 50 | You need to set `API_URL` environment variable to the URL at which Fed::BOX can be reached at: `https://fedbox.example.com` 51 | 52 | You need to set the `OAUTH2_KEY` to the Client ID: `4f449c81-1dbb-dead-beef-5a83926a0fbf` and the `OAUTH2_SECRET` to the password you supplied when adding the client. 53 | 54 | 55 | ## Running 56 | 57 | Running the application in development mode is as simple as: 58 | 59 | ```sh 60 | $ make run 61 | ``` 62 | -------------------------------------------------------------------------------- /doc/c2s.md: -------------------------------------------------------------------------------- 1 | # Querying FedBOX 2 | 3 | Unless specified all filters mentioned in [FedBOX](https://github.com/go-ap/fedbox/tree/master/doc/c2s.md) apply to the 4 | corresponding types of collections we're loading from. 5 | 6 | In the following scenarios, to get the full set of information we usually require more than the one request mentioned in them. 7 | 8 | For fully populating a littr.me Item, we require the following information besides the ActivityPub Object itself: 9 | 10 | * The item's author data. 11 | 12 | This means that for every items collection we are obtaining from fedbox, we must dereference and load all the `submittedBy` or `Actor` IRIs. 13 | This is done by loading the `/actors` end point with an IRI filter for all of the actor IRIs we want to dereference. 14 | 15 | eg: /actors?iri=https://federated.id/actors/{uuid1}&iri=https://federated.id/actors/{uuid2} 16 | 17 | * The item's votes data. 18 | 19 | This means that for every item collection, we must load all Like/Dislike/Undo Activities that have said item as an Object. 20 | This is done by loading the `/activities` end point with an Object filter for all the IRIs of the items. 21 | 22 | eg: /activities?type=Like&type=Dislike&type=Undo&object=https://federated.id/objects/{uuid1}&object=https://federated.id/object/{uuid2} 23 | 24 | For getting the full information about a (logged in) user, we also need to load his votes. 25 | 26 | The request to get them, should load the actor's `liked` collection where the object filter matches the IRIs of the objects we want to know the votes on. 27 | 28 | ## Loading main page items 29 | 30 | Loads FedBOX's service inbox `/inbox` 31 | 32 | ## Loading federated tab items 33 | 34 | Same as main page items, but the filtering should have the base IRI different than fedbox's host. 35 | 36 | **!** This isn't implemented yet, as fedbox doesn't support negating filter values. 37 | 38 | ## Loading followed items 39 | 40 | Loads the logged account's inbox. `/actors/{uuid}/inbox` 41 | 42 | ## Load discussions 43 | 44 | Loads the `/objects` end-point with a filter for Url being empty and Context being empty. 45 | 46 | It doesn't work by accessing the `/inbox` because it is an Activity collection, and we can't filter by the object's properties. 47 | 48 | ## Load items from a particular domain 49 | 50 | Loads the `/objects` end-point with a filter on Url to match the required domain. 51 | 52 | ## Load a user's items 53 | 54 | Loads the outbox of the actor corresponding to the user: `/actors/{uuid}/outbox` 55 | 56 | ## Load a user's votes 57 | 58 | Loads the liked end-point of the actor corresponding to the user: `/actors/{uuid}/liked` 59 | 60 | ## Load a user's pending follows 61 | 62 | Loads with filter type Follow from the inbox end-point of the actor corresponding to the user: `/actors/{uuid}/inbox?type=Follow` 63 | 64 | But we need to do remove all activities that have actors present in the in the `actors/{uuid}/followers` collection. 65 | 66 | ## Loading an item's comments 67 | 68 | Loads the item's Context property (which can be it's top level item, or itself) and loads the `/objects/{uuid}/replies` 69 | 70 | ## Loading an item's votes 71 | 72 | Loads the item's like collection `/objects/{uuid}/likes` 73 | 74 | # Saving to FedBOX 75 | 76 | 77 | -------------------------------------------------------------------------------- /doc/todo.md: -------------------------------------------------------------------------------- 1 | # Issues 2 | 3 | * Unify report/block/reply models, cursors. 4 | * Unify msg user/add new submission models, cursors. 5 | * Separate CSS for media queries to different files 6 | * When adding a new OAuth2 client from the command line, we shouldn't allow password flow by default, but based on a parameter when creating it. 7 | * ~~Add local override of broccoli cli to allow minification at go generate time~~ 8 | * ~~Moderation page fails~~ 9 | * ~~Ensure latest fedbox/go-littr works on qa/live~~ 10 | * ~~Fix sessions handling when logging in~~ 11 | * ~~Show lock icon when replying to private message.~~ 12 | * ~~Merge assets to a single tidyfied file when executing go generate~~, and ... 13 | * ~~Show "reported" label for items that the logged user already reported.~~ 14 | * ~~Currently the Flag/Block activities have issues with recipients ... FIX(ed) IT!~~ 15 | * ~~Refactor the fedbox API client and the filters overall. Main issue currently:~~ 16 | * ~~When loading an Activity collection, dereference the Objects in it and load those from the /objects end-point~~ 17 | * ~~Use the ActivityPub client.LoadIRI method instead of manual Get and processing of incoming response~~ 18 | * Audience improvements: 19 | * ~~Add all @mentions to the CC field - this just got a bit easier as we can send multiple Objects on a Create activity.~~ 20 | * ~~Move local instance from To to BCC field~~ 21 | * ~~Add the attributedTo of the item replied to, to the To field~~ 22 | * ~~Fix @mentions and #tags parsing.~~ 23 | * ~~Going to a reply, doesn't load it's children.~~ 24 | * ~~Registered actors are missing quite a lot of fields: `Published`, `Updated`, `Endpoints`, `Url`.~~ 25 | * ~~All children objects should be added to the OP's replies collection.~~ 26 | * ~~After submitting a Like/Dislike, it seems we can't Undo or do the reverse of it.~~ 27 | * ~~A logged in user seems to be able to be allowed to edit/delete Anonymous objects.~~ 28 | * ~~A logged in user doesn't seem to be able to edit/delete his own objects.~~ 29 | -------------------------------------------------------------------------------- /fedbox_test.go: -------------------------------------------------------------------------------- 1 | package brutalinks 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | ) 7 | 8 | func Test_RawFilterQuery(t *testing.T) { 9 | { 10 | var func1 = func() url.Values { 11 | return nil 12 | } 13 | mustBeEmpty := rawFilterQuery(func1) 14 | testVal := "" 15 | if mustBeEmpty != testVal { 16 | t.Errorf("Value must be %q, received %q", testVal, mustBeEmpty) 17 | } 18 | } 19 | { 20 | var func1 = func() url.Values { 21 | return url.Values{ 22 | "iri": {"ana", "are", "mere"}, 23 | } 24 | } 25 | testVal := "?iri=ana&iri=are&iri=mere" 26 | anaAreMere := rawFilterQuery(func1) 27 | if anaAreMere != testVal { 28 | t.Errorf("Value must be %q string, received %q", testVal, anaAreMere) 29 | } 30 | } 31 | { 32 | var func1 = func() url.Values { 33 | return url.Values{ 34 | "iri": {"ana", "are", "mere"}, 35 | } 36 | } 37 | var func2 = func() url.Values { 38 | return url.Values{ 39 | "iri": {"foo", "bar"}, 40 | "type": {"typ"}, 41 | } 42 | } 43 | testVal := "?iri=ana&iri=are&iri=mere&iri=foo&iri=bar&type=typ" 44 | anaAreMere := rawFilterQuery(func1, func2) 45 | if anaAreMere != testVal { 46 | t.Errorf("Value must be %q, received %q", testVal, anaAreMere) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /federated.go: -------------------------------------------------------------------------------- 1 | package brutalinks 2 | 3 | type FedInstance struct { 4 | BaseURL string 5 | SharedInbox string 6 | Name string 7 | Description string 8 | Email string 9 | } 10 | -------------------------------------------------------------------------------- /follows.go: -------------------------------------------------------------------------------- 1 | package brutalinks 2 | 3 | import ( 4 | "time" 5 | 6 | vocab "github.com/go-ap/activitypub" 7 | "github.com/go-ap/errors" 8 | ) 9 | 10 | // FollowRequests 11 | type FollowRequests []FollowRequest 12 | 13 | // FollowRequest 14 | type FollowRequest struct { 15 | Hash Hash `json:"hash"` 16 | SubmittedAt time.Time `json:"-"` 17 | SubmittedBy *Account `json:"by,omitempty"` 18 | Object *Account `json:"-"` 19 | Metadata *ActivityMetadata `json:"-"` 20 | pub vocab.Item `json:"-"` 21 | Flags FlagBits `json:"flags,omitempty"` 22 | } 23 | 24 | // ActivityMetadata 25 | type ActivityMetadata struct { 26 | ID string `json:"-"` 27 | InReplyTo vocab.IRIs `json:"-"` 28 | } 29 | 30 | func (f *FollowRequest) ID() Hash { 31 | if f == nil { 32 | return AnonymousHash 33 | } 34 | return f.Hash 35 | } 36 | 37 | // FromActivityPub 38 | func (f *FollowRequest) FromActivityPub(it vocab.Item) error { 39 | if f == nil { 40 | return nil 41 | } 42 | if vocab.IsNil(it) { 43 | return errors.Newf("nil item received") 44 | } 45 | f.pub = it 46 | if it.IsLink() { 47 | iri := it.GetLink() 48 | f.Hash.FromActivityPub(iri) 49 | f.Metadata = &ActivityMetadata{ 50 | ID: iri.String(), 51 | } 52 | return nil 53 | } 54 | return vocab.OnActivity(it, func(a *vocab.Activity) error { 55 | err := f.Hash.FromActivityPub(a) 56 | if err != nil { 57 | return err 58 | } 59 | wer := new(Account) 60 | err = wer.FromActivityPub(a.Actor) 61 | if err != nil { 62 | return err 63 | } 64 | f.SubmittedBy = wer 65 | wed := new(Account) 66 | err = wed.FromActivityPub(a.Object) 67 | if err != nil { 68 | return err 69 | } 70 | f.Object = wed 71 | f.SubmittedAt = a.Published 72 | f.Metadata = &ActivityMetadata{ 73 | ID: string(a.ID), 74 | } 75 | if a.InReplyTo != nil { 76 | f.Metadata.InReplyTo = make(vocab.IRIs, 0) 77 | vocab.OnCollectionIntf(a.InReplyTo, func(col vocab.CollectionInterface) error { 78 | for _, it := range col.Collection() { 79 | f.Metadata.InReplyTo = append(f.Metadata.InReplyTo, it.GetLink()) 80 | } 81 | return nil 82 | }) 83 | } 84 | return nil 85 | }) 86 | } 87 | 88 | // Type 89 | func (f *FollowRequest) Type() RenderType { 90 | return FollowType 91 | } 92 | 93 | // Date 94 | func (f FollowRequest) Date() time.Time { 95 | return f.SubmittedAt 96 | } 97 | 98 | func (f *FollowRequest) Children() *RenderableList { 99 | return nil 100 | } 101 | 102 | // Private 103 | func (f *FollowRequest) Private() bool { 104 | return f.Flags&FlagsPrivate == FlagsPrivate 105 | } 106 | 107 | // Deleted 108 | func (f *FollowRequest) Deleted() bool { 109 | return f.Flags&FlagsDeleted == FlagsDeleted 110 | } 111 | 112 | // IsValid returns if the current follow request has a hash with length greater than 0 113 | func (f *FollowRequest) IsValid() bool { 114 | return f != nil && f.Hash.IsValid() 115 | } 116 | 117 | // AP returns the underlying actvitypub item 118 | func (f *FollowRequest) AP() vocab.Item { 119 | return f.pub 120 | } 121 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module git.sr.ht/~mariusor/brutalinks 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | git.sr.ht/~mariusor/assets v0.0.0-20241011130619-ac139c364a49 7 | git.sr.ht/~mariusor/box v0.0.0-20250415084235-46ddd33ee3da 8 | git.sr.ht/~mariusor/cache v0.0.0-20250122165545-14c90d7a9de8 9 | git.sr.ht/~mariusor/lw v0.0.0-20250325163623-1639f3fb0e0d 10 | git.sr.ht/~mariusor/mask v0.0.0-20250114195353-98705a6977b7 11 | git.sr.ht/~mariusor/wrapper v0.0.0-20250414202025-7af98c35299c 12 | github.com/go-ap/activitypub v0.0.0-20250409143848-7113328b1f3d 13 | github.com/go-ap/cache v0.0.0-20250409143941-46ead8c57c50 14 | github.com/go-ap/client v0.0.0-20250409144111-73642f11a3cf 15 | github.com/go-ap/errors v0.0.0-20250409143711-5686c11ae650 16 | github.com/go-ap/filters v0.0.0-20250409144015-c6cbbadeefe4 17 | github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 18 | github.com/go-chi/chi/v5 v5.2.1 19 | github.com/google/uuid v1.6.0 20 | github.com/gorilla/csrf v1.7.3 21 | github.com/gorilla/sessions v1.4.0 22 | github.com/joho/godotenv v1.5.1 23 | github.com/mariusor/qstring v0.0.0-20200204164351-5a99d46de39d 24 | github.com/mariusor/render v1.5.1-0.20221026090743-ab78c1b3aa95 25 | github.com/microcosm-cc/bluemonday v1.0.27 26 | github.com/openshift/osin v1.0.1 27 | github.com/tdewolff/minify v2.3.6+incompatible 28 | github.com/writeas/go-nodeinfo v1.0.0 29 | gitlab.com/golang-commonmark/markdown v0.0.0-20211110145824-bf3e522c626a 30 | gitlab.com/golang-commonmark/puny v0.0.0-20191124015043-9f83538fa04f 31 | golang.org/x/oauth2 v0.29.0 32 | golang.org/x/text v0.24.0 33 | ) 34 | 35 | require ( 36 | git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect 37 | git.sr.ht/~mariusor/ssm v0.0.0-20241220163816-32d18afe7b22 // indirect 38 | git.sr.ht/~mariusor/tagextractor v0.0.0-20240907091823-17f6587b742f // indirect 39 | git.sr.ht/~mariusor/vocab-bubbles v0.0.0-20250327151302-59debb9517ba // indirect 40 | github.com/RoaringBitmap/roaring v1.9.4 // indirect 41 | github.com/atotto/clipboard v0.1.4 // indirect 42 | github.com/aymerick/douceur v0.2.0 // indirect 43 | github.com/bits-and-blooms/bitset v1.22.0 // indirect 44 | github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 // indirect 45 | github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 // indirect 46 | github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 // indirect 47 | github.com/charmbracelet/colorprofile v0.3.0 // indirect 48 | github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 // indirect 49 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 50 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 51 | github.com/charmbracelet/x/input v0.3.4 // indirect 52 | github.com/charmbracelet/x/term v0.2.1 // indirect 53 | github.com/charmbracelet/x/windows v0.2.0 // indirect 54 | github.com/elnormous/contenttype v1.0.4 // indirect 55 | github.com/go-fed/httpsig v1.1.0 // indirect 56 | github.com/gorilla/css v1.0.1 // indirect 57 | github.com/gorilla/securecookie v1.1.2 // indirect 58 | github.com/jdkato/prose v1.2.1 // indirect 59 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 60 | github.com/mariusor/bubbles-tree v0.0.0-20250327140737-63476ad35750 // indirect 61 | github.com/mattn/go-colorable v0.1.14 // indirect 62 | github.com/mattn/go-isatty v0.0.20 // indirect 63 | github.com/mattn/go-runewidth v0.0.16 // indirect 64 | github.com/mschoch/smat v0.2.0 // indirect 65 | github.com/muesli/cancelreader v0.2.2 // indirect 66 | github.com/muesli/reflow v0.3.0 // indirect 67 | github.com/pborman/uuid v1.2.1 // indirect 68 | github.com/rivo/uniseg v0.4.7 // indirect 69 | github.com/rs/xid v1.6.0 // indirect 70 | github.com/rs/zerolog v1.34.0 // indirect 71 | github.com/spaolacci/murmur3 v1.1.0 // indirect 72 | github.com/tdewolff/parse v2.3.4+incompatible // indirect 73 | github.com/tdewolff/test v1.0.7 // indirect 74 | github.com/valyala/fastjson v1.6.4 // indirect 75 | github.com/writeas/go-webfinger v1.1.0 // indirect 76 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 77 | gitlab.com/golang-commonmark/html v0.0.0-20191124015941-a22733972181 // indirect 78 | gitlab.com/golang-commonmark/linkify v0.0.0-20200225224916-64bca66f6ad3 // indirect 79 | gitlab.com/golang-commonmark/mdurl v0.0.0-20191124015652-932350d1cb84 // indirect 80 | go.etcd.io/bbolt v1.4.0 // indirect 81 | golang.org/x/crypto v0.37.0 // indirect 82 | golang.org/x/net v0.39.0 // indirect 83 | golang.org/x/sync v0.13.0 // indirect 84 | golang.org/x/sys v0.32.0 // indirect 85 | gopkg.in/neurosnap/sentences.v1 v1.0.7 // indirect 86 | ) 87 | -------------------------------------------------------------------------------- /hashes.go: -------------------------------------------------------------------------------- 1 | package brutalinks 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | vocab "github.com/go-ap/activitypub" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // Hash is a local type for string, it should hold a [32]byte array actually 12 | type Hash uuid.UUID 13 | 14 | // AnonymousHash is the sha hash for the anonymous account 15 | var ( 16 | AnonymousHash = Hash{} 17 | SystemHash = Hash{0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} 18 | ) 19 | 20 | func HashFromIRI(i vocab.IRI) Hash { 21 | //_, h := path.Split() 22 | pieces := strings.Split(strings.TrimRight(i.String(), "/"), "/") 23 | for i := len(pieces) - 1; i >= 0; i-- { 24 | piece := pieces[i] 25 | if h := HashFromString(piece); h != AnonymousHash { 26 | return h 27 | } 28 | } 29 | return AnonymousHash 30 | } 31 | 32 | func HashFromItem(obj vocab.Item) Hash { 33 | if obj == nil { 34 | return AnonymousHash 35 | } 36 | iri := obj.GetLink() 37 | if len(iri) == 0 { 38 | return AnonymousHash 39 | } 40 | return HashFromIRI(iri) 41 | } 42 | 43 | func HashFromString(s string) Hash { 44 | if len(s) == 0 { 45 | return AnonymousHash 46 | } 47 | if _, err := strconv.ParseInt(s, 10, 64); err == nil { 48 | hh := [16]byte{} 49 | bs := []byte(s) 50 | st := len(bs) - 1 51 | eh := len(hh) - 1 52 | for i := st; i >= 0; i-- { 53 | hh[eh] = bs[i] 54 | eh-- 55 | if eh == 0 { 56 | break 57 | } 58 | } 59 | return hh 60 | } 61 | if u, err := uuid.Parse(s); err == nil { 62 | return Hash(u) 63 | } 64 | return AnonymousHash 65 | } 66 | 67 | // String returns the hash as a string 68 | func (h Hash) String() string { 69 | return uuid.UUID(h).String() 70 | } 71 | 72 | // MarshalText 73 | func (h Hash) MarshalText() ([]byte, error) { 74 | return []byte(h.String()), nil 75 | } 76 | 77 | func (h Hash) IsValid() bool { 78 | return uuid.UUID(h).Time() > 0 79 | } 80 | 81 | type Hashes []Hash 82 | 83 | func (h Hashes) Contains(s Hash) bool { 84 | for _, hh := range h { 85 | if hh == s { 86 | return true 87 | } 88 | } 89 | return false 90 | } 91 | 92 | func (h Hashes) String() string { 93 | str := make([]string, len(h)) 94 | for i, hh := range h { 95 | str[i] = hh.String() 96 | } 97 | return strings.Join(str, ", ") 98 | } 99 | -------------------------------------------------------------------------------- /hashes_test.go: -------------------------------------------------------------------------------- 1 | package brutalinks 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestHashFromString(t *testing.T) { 9 | type args struct { 10 | s string 11 | } 12 | tests := []struct { 13 | name string 14 | val string 15 | want Hash 16 | }{ 17 | { 18 | name: "random valid value", 19 | val: "6435b2b5-26df-434c-87ca-58ddab49fcc8", 20 | want: Hash{100, 53, 178, 181, 38, 223, 67, 76, 135, 202, 88, 221, 171, 73, 252, 200}, 21 | }, 22 | { 23 | name: "invalid value", 24 | val: "435b2b5-26df-434c-87ca-58ddab49fcc8", 25 | want: Hash{}, 26 | }, 27 | { 28 | name: "empty value", 29 | val: "", 30 | want: Hash{}, 31 | }, 32 | } 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | if got := HashFromString(tt.val); !reflect.DeepEqual(got, tt.want) { 36 | t.Errorf("HashFromString() = %v, want %v", got, tt.want) 37 | } 38 | }) 39 | } 40 | } 41 | 42 | func TestHash_MarshalText(t *testing.T) { 43 | tests := []struct { 44 | name string 45 | h Hash 46 | want []byte 47 | wantErr bool 48 | }{ 49 | { 50 | name: "valid value", 51 | h: Hash{100, 53, 178, 181, 38, 223, 67, 76, 135, 202, 88, 221, 171, 73, 252, 200}, 52 | want: []byte("6435b2b5-26df-434c-87ca-58ddab49fcc8"), 53 | wantErr: false, 54 | }, 55 | } 56 | for _, tt := range tests { 57 | t.Run(tt.name, func(t *testing.T) { 58 | got, err := tt.h.MarshalText() 59 | if (err != nil) != tt.wantErr { 60 | t.Errorf("MarshalText() error = %v, wantErr %v", err, tt.wantErr) 61 | return 62 | } 63 | if !reflect.DeepEqual(got, tt.want) { 64 | t.Errorf("MarshalText() got = %v, want %v", got, tt.want) 65 | } 66 | }) 67 | } 68 | } 69 | 70 | func TestHash_String(t *testing.T) { 71 | tests := []struct { 72 | name string 73 | h Hash 74 | want string 75 | }{ 76 | { 77 | name: "valid value", 78 | h: Hash{100, 53, 178, 181, 38, 223, 67, 76, 135, 202, 88, 221, 171, 73, 252, 200}, 79 | want: "6435b2b5-26df-434c-87ca-58ddab49fcc8", 80 | }, 81 | } 82 | for _, tt := range tests { 83 | t.Run(tt.name, func(t *testing.T) { 84 | if got := tt.h.String(); got != tt.want { 85 | t.Errorf("String() = %v, want %v", got, tt.want) 86 | } 87 | }) 88 | } 89 | } 90 | 91 | func TestHash_Valid(t *testing.T) { 92 | tests := []struct { 93 | name string 94 | h Hash 95 | want bool 96 | }{ 97 | { 98 | name: "valid value", 99 | h: Hash{100, 53, 178, 181, 38, 223, 67, 76, 135, 202, 88, 221, 171, 73, 252, 200}, 100 | want: true, 101 | }, 102 | { 103 | name: "invalid value", 104 | h: Hash{}, 105 | want: false, 106 | }, 107 | { 108 | name: "valid value, but mostly nils", 109 | h: Hash{100}, 110 | want: true, 111 | }, 112 | } 113 | for _, tt := range tests { 114 | t.Run(tt.name, func(t *testing.T) { 115 | if got := tt.h.IsValid(); got != tt.want { 116 | t.Errorf("Valid(%s) = %v, want %v", tt.h, got, tt.want) 117 | } 118 | }) 119 | } 120 | } 121 | 122 | func TestHashes_Contains(t *testing.T) { 123 | type args struct { 124 | s Hash 125 | } 126 | tests := []struct { 127 | name string 128 | h Hashes 129 | args args 130 | want bool 131 | }{ 132 | { 133 | name: "value contained", 134 | h: Hashes{Hash{100, 53, 178, 181, 38, 223, 67, 76, 135, 202, 88, 221, 171, 73, 252, 200}}, 135 | args: args{Hash{100, 53, 178, 181, 38, 223, 67, 76, 135, 202, 88, 221, 171, 73, 252, 200}}, 136 | want: true, 137 | }, 138 | { 139 | name: "value not contained", 140 | h: Hashes{Hash{100, 53, 178, 181, 38, 223, 67, 76, 135, 202, 88, 221, 171, 73, 252, 200}}, 141 | args: args{Hash{101, 53, 178, 181, 38, 223, 67, 76, 135, 202, 88, 221, 171, 73, 252, 200}}, 142 | want: false, 143 | }, 144 | } 145 | for _, tt := range tests { 146 | t.Run(tt.name, func(t *testing.T) { 147 | if got := tt.h.Contains(tt.args.s); got != tt.want { 148 | t.Errorf("Contains() = %v, want %v", got, tt.want) 149 | } 150 | }) 151 | } 152 | } 153 | 154 | func TestHashes_String(t *testing.T) { 155 | tests := []struct { 156 | name string 157 | h Hashes 158 | want string 159 | }{ 160 | { 161 | name: "one value", 162 | h: Hashes{Hash{100, 53, 178, 181, 38, 223, 67, 76, 135, 202, 88, 221, 171, 73, 252, 200}}, 163 | want: "6435b2b5-26df-434c-87ca-58ddab49fcc8", 164 | }, 165 | { 166 | name: "two values", 167 | h: Hashes{ 168 | Hash{100, 53, 178, 181, 38, 223, 67, 76, 135, 202, 88, 221, 171, 73, 252, 200}, 169 | Hash{101, 53, 178, 181, 38, 223, 67, 76, 135, 202, 88, 221, 171, 73, 252, 200}, 170 | }, 171 | want: "6435b2b5-26df-434c-87ca-58ddab49fcc8, 6535b2b5-26df-434c-87ca-58ddab49fcc8", 172 | }, 173 | } 174 | for _, tt := range tests { 175 | t.Run(tt.name, func(t *testing.T) { 176 | if got := tt.h.String(); got != tt.want { 177 | t.Errorf("String() = %v, want %v", got, tt.want) 178 | } 179 | }) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /hotscore.go: -------------------------------------------------------------------------------- 1 | package brutalinks 2 | 3 | import ( 4 | "math" 5 | "time" 6 | ) 7 | 8 | // represents the statistical confidence 9 | // var StatisticalConfidence = 1.0 => ~69%, 1.96 => ~95% (default) 10 | var StatisticalConfidence = 1.94 11 | 12 | // represents how fast elapsed hours affect the order of an item 13 | var HNGravity = 1.5 14 | 15 | // wilson score interval sort 16 | // http://www.evanmiller.org/how-not-to-sort-by-average-rating.html 17 | func Wilson(ups, downs int64) float64 { 18 | n := ups + downs 19 | if n == 0 { 20 | return 0 21 | } 22 | 23 | n1 := float64(n) 24 | z := StatisticalConfidence 25 | p := float64(ups / n) 26 | zzfn := z * z / (4 * n1) 27 | w := (p + 2.0*zzfn - z*math.Sqrt((zzfn/n1+p*(1.0-p))/n1)) / (1 + 4*zzfn) 28 | 29 | return w 30 | } 31 | 32 | // Hacker hackernews' hot sort 33 | // https://medium.com/hacking-and-gonzo/how-hacker-news-ranking-algorithm-works-1d9b0cf2c08d 34 | func Hacker(votes int64, date time.Duration) float64 { 35 | secondsAge := date.Seconds() 36 | ageDelta := 2 * time.Hour.Seconds() 37 | return float64(votes) / math.Pow(secondsAge+ageDelta, HNGravity) 38 | } 39 | 40 | // Reddit reddit's hot sort 41 | // http://amix.dk/blog/post/19588 42 | func Reddit(ups, downs int64, date time.Duration) float64 { 43 | decay := 45000.0 44 | s := float64(ups - downs) 45 | order := math.Log(math.Max(math.Abs(s), 1)) / math.Ln10 46 | return order - date.Seconds()/float64(decay) 47 | } 48 | -------------------------------------------------------------------------------- /images/Makefile: -------------------------------------------------------------------------------- 1 | SHELL := bash 2 | .ONESHELL: 3 | .SHELLFLAGS := -eu -o pipefail -c 4 | .DELETE_ON_ERROR: 5 | MAKEFLAGS += --warn-undefined-variables 6 | MAKEFLAGS += --no-builtin-rules 7 | 8 | ENV ?= dev 9 | APP_HOSTNAME ?= brutalinks.local 10 | PORT ?= 4001 11 | VERSION ?= HEAD 12 | ENV_FILE ?= $(shell realpath .env.$(ENV)) 13 | GO_VERSION ?= 1.23 14 | 15 | TAG_CMD=podman tag 16 | PUSH_CMD=podman push 17 | 18 | .PHONY: clean images cert build builder run push start 19 | 20 | .DEFAULT_GOAL := help 21 | 22 | help: ## Help target that shows this message. 23 | @sed -rn 's/^([^:]+):.*[ ]##[ ](.+)/\1:\2/p' $(MAKEFILE_LIST) | column -ts: -l2 24 | 25 | $(ENV_FILE): 26 | touch $(ENV_FILE) 27 | 28 | $(APP_HOSTNAME).pem: 29 | ./gen-certs.sh $(APP_HOSTNAME) 30 | 31 | cert: $(APP_HOSTNAME).pem ## Builds the certificate to include in the image. 32 | 33 | clean: ## Clean the workspace. 34 | @-$(RM) $(APP_HOSTNAME).{key,crt,pem} 35 | 36 | builder: ## Build the builder image for compiling the Brutalinks application. 37 | ./build.sh .. brutalinks/builder 38 | 39 | build: ## Build the Brutalinks application. 40 | ENV="$(ENV)" VERSION="$(VERSION)" PORT="$(PORT)" APP_HOSTNAME="$(APP_HOSTNAME)" ./image.sh "brutalinks/app:$(ENV)" 41 | 42 | push: build ## Push the Brutalinks application images to Quay.io. 43 | $(TAG_CMD) brutalinks/app:$(ENV) quay.io/go-ap/brutalinks:$(ENV) 44 | $(PUSH_CMD) quay.io/go-ap/brutalinks:$(ENV) 45 | ifeq ($(ENV),dev) 46 | $(TAG_CMD) brutalinks/app:$(ENV) quay.io/go-ap/brutalinks:latest 47 | $(PUSH_CMD) quay.io/go-ap/brutalinks:latest || true 48 | endif 49 | ifneq ($(VERSION),) 50 | $(TAG_CMD) brutalinks/app:$(ENV) quay.io/go-ap/brutalinks:$(VERSION)-$(ENV) || true 51 | $(PUSH_CMD) quay.io/go-ap/brutalinks:$(VERSION)-$(ENV) || true 52 | endif 53 | -------------------------------------------------------------------------------- /images/bootstrap/.dockerignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | doc/ 3 | tests/fedbox/fs/test/fedbox/oauth/access/* 4 | tests/fedbox/fs/test/fedbox/oauth/refresh/* 5 | -------------------------------------------------------------------------------- /images/bootstrap/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ENV 2 | FROM quay.io/go-ap/fedbox:${ENV:-prod} as fedbox 3 | 4 | FROM alpine 5 | 6 | ARG FEDBOX_HOSTNAME 7 | ARG OAUTH2_CALLBACK_URL 8 | ARG OAUTH2_SECRET 9 | ARG ADMIN_PW 10 | 11 | VOLUME /storage 12 | 13 | ENV FEDBOX_HOSTNAME $FEDBOX_HOSTNAME 14 | ENV ENV $ENV 15 | ENV OAUTH2_SECRET $OAUTH2_SECRET 16 | ENV ADMIN_PW $ADMIN_PW 17 | ENV OAUTH2_CALLBACK_URL $OAUTH2_CALLBACK_URL 18 | 19 | COPY --from=fedbox /bin/ctl /bin/ctl 20 | COPY bootstrap.sh /bin/bootstrap.sh 21 | COPY useradd.sh /bin/useradd.sh 22 | COPY clientadd.sh /bin/clientadd.sh 23 | 24 | RUN apk update && apk add jq expect && rm -rf /var/cache/apk 25 | -------------------------------------------------------------------------------- /images/bootstrap/bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [[ -z "${FEDBOX_HOSTNAME}" ]]; then 4 | echo "Missing fedbox hostname in environment"; 5 | exit 1 6 | fi 7 | if [[ -z "${OAUTH2_SECRET}" ]]; then 8 | echo "Missing OAuth2 password in environment"; 9 | exit 1 10 | fi 11 | if [[ -z "${OAUTH2_CALLBACK_URL}" ]]; then 12 | echo "Missing OAuth2 callback url in environment"; 13 | exit 1 14 | fi 15 | 16 | _ENV_FILE=/.env 17 | 18 | if [ ! -f "${_ENV_FILE}" ]; then 19 | echo "Invalid .env file ${_ENV_FILE}" 20 | exit 1 21 | fi 22 | 23 | _FULL_PATH="${STORAGE_PATH}/${ENV}/${FEDBOX_HOSTNAME}" 24 | if [[ -d "${_FULL_PATH}" ]]; then 25 | echo "skipping bootstrapping ${_FULL_PATH}" 26 | else 27 | echo "# bootstrapped application $(date -u -Iseconds)" >> ${_ENV_FILE} 28 | # create storage 29 | ctl bootstrap 30 | fi 31 | 32 | _HAVE_OAUTH2_SECRET=$(grep OAUTH2_SECRET "${_ENV_FILE}" | cut -d'=' -f2 | tail -n1) 33 | _HAVE_OAUTH2_CLIENT=$(ctl oauth client ls | grep -c "${OAUTH2_KEY}") 34 | 35 | if [[ ${_HAVE_OAUTH2_CLIENT} -ge 1 && "z${_HAVE_OAUTH2_SECRET}" == "z${OAUTH2_SECRET}" ]]; then 36 | echo "skipping adding OAuth2 client" 37 | else 38 | # add oauth2 client for littr.me 39 | echo OAUTH2_KEY=$(clientadd.sh "${OAUTH2_SECRET}" "${OAUTH2_CALLBACK_URL}" | grep Client | tail -1 | awk '{print $3}') >> .env 40 | echo OAUTH2_SECRET="${OAUTH2_SECRET}" >> .env 41 | fi 42 | 43 | _ADMIN_NAME=admin 44 | _HAVE_ADMIN=$(ctl ap ls --type Person | jq -r .[].preferredUsername | grep -c "${_ADMIN_NAME}") 45 | if [[ ${_HAVE_ADMIN} -ge 1 ]]; then 46 | echo "skipping adding user ${_ADMIN_NAME}" 47 | else 48 | if [[ -n "${ADMIN_PW}" ]]; then 49 | # add admin user for littr.me 50 | useradd.sh "${_ADMIN_NAME}" "${ADMIN_PW}" 51 | fi 52 | fi 53 | -------------------------------------------------------------------------------- /images/bootstrap/clientadd.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/expect -f 2 | # Special thanks to github.com/squash for providing this script in the course of solving 3 | # https://github.com/mariusor/go-littr/issues/38#issuecomment-658800183 4 | 5 | set pass [lindex $argv 0] 6 | set callback_url [lindex $argv 1] 7 | 8 | spawn ctl oauth client add --redirectUri "${callback_url}" 9 | expect "client's pw: " 10 | send "${pass}\r" 11 | expect "pw again: " 12 | send "${pass}\r" 13 | expect eof 14 | 15 | -------------------------------------------------------------------------------- /images/bootstrap/useradd.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/expect -f 2 | # Special thanks to github.com/squash for providing this script in the course of solving 3 | # https://github.com/mariusor/go-littr/issues/38#issuecomment-658800183 4 | 5 | set name [lindex $argv 0] 6 | set pass [lindex $argv 1] 7 | 8 | spawn ctl ap actor add ${name} 9 | expect "admin's pw: " 10 | send "${pass}\r" 11 | expect "pw again: " 12 | send "${pass}\r" 13 | expect eof 14 | 15 | -------------------------------------------------------------------------------- /images/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | #set -x 4 | 5 | _workdir=${1:-../} 6 | _image_name=${2:-brutalinks/builder} 7 | _go_version=${GO_VERSION:-1.24} 8 | 9 | _context=$(realpath "${_workdir}") 10 | 11 | _builder=$(buildah from "docker.io/library/golang:${_go_version}-alpine") 12 | buildah run "${_builder}" /sbin/apk update 13 | buildah run "${_builder}" /sbin/apk add git make bash openssl upx 14 | 15 | buildah config --env GO111MODULE=on "${_builder}" 16 | buildah config --env GOWORK=off "${_builder}" 17 | buildah config --env GOPROXY=direct "${_builder}" 18 | 19 | buildah copy --ignorefile "${_context}/.containerignore" --contextdir "${_context}" "${_builder}" "${_context}" /go/src/app 20 | 21 | buildah config --workingdir /go/src/app "${_builder}" 22 | 23 | buildah run "${_builder}" make go.sum 24 | buildah run "${_builder}" go mod vendor 25 | 26 | # commit 27 | buildah commit "${_builder}" "${_image_name}" 28 | -------------------------------------------------------------------------------- /images/gen-certs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | APP_HOSTNAME="${1}" 3 | 4 | openssl req \ 5 | -subj "/C=AQ/ST=Omond/L=Omond/O=${APP_HOSTNAME}/OU=none/CN=${APP_HOSTNAME}" \ 6 | -newkey rsa:2048 -sha256 \ 7 | -keyout "${APP_HOSTNAME}.key" \ 8 | -nodes -x509 -days 365 \ 9 | -out "${APP_HOSTNAME}.crt" && \ 10 | cat "${APP_HOSTNAME}.key" "${APP_HOSTNAME}.crt" > "${APP_HOSTNAME}.pem" 11 | -------------------------------------------------------------------------------- /images/image.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | #set -x 4 | 5 | _environment=${ENV:-dev} 6 | _hostname=${APP_HOSTNAME:-brutalinks} 7 | _listen_port=${PORT:-3003} 8 | _version=${VERSION:-HEAD} 9 | 10 | _image_name=${1:-brutalinks:${_environment}} 11 | _build_name=${2:-localhost/brutalinks/builder} 12 | 13 | _builder=$(buildah from "${_build_name}":latest) 14 | if [[ -z "${_builder}" ]]; then 15 | echo "Unable to find builder image: ${_build_name}" 16 | exit 1 17 | fi 18 | 19 | echo "Building image ${_image_name} for host:${_hostname} env:${_environment} port:${_listen_port} version:${_version}" 20 | 21 | buildah run "${_builder}" make ENV="${_environment}" VERSION="${_version}" all 22 | buildah run "${_builder}" ./images/gen-certs.sh ${_hostname} 23 | 24 | _image=$(buildah from gcr.io/distroless/static:latest) 25 | 26 | buildah config --env ENV="${_environment}" "${_image}" 27 | buildah config --env APP_HOSTNAME="${_hostname}" "${_image}" 28 | buildah config --env LISTEN=:"${_listen_port}" "${_image}" 29 | buildah config --env KEY_PATH="/etc/ssl/certs/${_hostname}.key" "${_image}" 30 | buildah config --env CERT_PATH="/etc/ssl/certs/${_hostname}.crt" "${_image}" 31 | buildah config --env HTTPS=true "${_image}" 32 | 33 | buildah config --port "${_listen_port}" "${_image}" 34 | 35 | buildah config --volume /storage "${_image}" 36 | buildah config --volume /.env "${_image}" 37 | 38 | buildah copy --from "${_builder}" "${_image}" /go/src/app/bin/* /bin/ 39 | buildah copy --from "${_builder}" "${_image}" "/go/src/app/${_hostname}.key" /etc/ssl/certs/ 40 | buildah copy --from "${_builder}" "${_image}" "/go/src/app/${_hostname}.crt" /etc/ssl/certs/ 41 | buildah copy --from "${_builder}" "${_image}" "/go/src/app/${_hostname}.pem" /etc/ssl/certs/ 42 | 43 | if [ "${_environment}" = "dev" ]; then 44 | buildah copy --from "${_builder}" "${_image}" /go/src/app/templates /templates 45 | buildah copy --from "${_builder}" "${_image}" /go/src/app/assets /assets 46 | buildah copy --from "${_builder}" "${_image}" /go/src/app/README.md /README.md 47 | 48 | buildah config --workingdir / "${_image}" 49 | fi 50 | 51 | buildah config --entrypoint '["/bin/brutalinks"]' "${_image}" 52 | 53 | # commit 54 | buildah commit "${_image}" "${_image_name}" 55 | -------------------------------------------------------------------------------- /internal/assets/assets_dev.go: -------------------------------------------------------------------------------- 1 | //go:build !(prod || qa) 2 | 3 | package assets 4 | 5 | import ( 6 | "fmt" 7 | "io/fs" 8 | "mime" 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | "time" 13 | 14 | "git.sr.ht/~mariusor/assets" 15 | "github.com/go-ap/errors" 16 | ) 17 | 18 | var ( 19 | rootPath, _ = filepath.Abs("./") 20 | rootFS = os.DirFS(rootPath) 21 | assetFS, _ = fs.Sub(rootFS, "assets") 22 | AssetFS = assets.Aggregate(assetFS, rootFS) 23 | 24 | TemplateFS = rootFS 25 | ) 26 | 27 | func Write(s fs.FS, errFn func(http.ResponseWriter, *http.Request, ...error)) func(http.ResponseWriter, *http.Request) { 28 | const cacheTime = 8766 * time.Hour 29 | 30 | mime.AddExtensionType(".ico", "image/vnd.microsoft.icon") 31 | mime.AddExtensionType(".txt", "text/plain; charset=utf-8") 32 | return func(w http.ResponseWriter, r *http.Request) { 33 | asset := r.RequestURI 34 | mimeType := mime.TypeByExtension(filepath.Ext(asset)) 35 | 36 | buf, err := fs.ReadFile(s, asset) 37 | if err != nil { 38 | if errors.Is(err, os.ErrNotExist) { 39 | err = errors.NewNotFound(err, asset) 40 | } 41 | errFn(w, r, err) 42 | return 43 | } 44 | 45 | w.Header().Set("Cache-Control", fmt.Sprintf("public,max-age=%d", int(cacheTime.Seconds()))) 46 | if mimeType != "" { 47 | w.Header().Set("Content-Type", mimeType) 48 | } 49 | w.Write(buf) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /internal/assets/assets_prod.go: -------------------------------------------------------------------------------- 1 | //go:build prod || qa 2 | 3 | package assets 4 | 5 | import ( 6 | "fmt" 7 | "io/fs" 8 | "mime" 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | "time" 13 | 14 | "github.com/go-ap/errors" 15 | ) 16 | 17 | func Write(s fs.FS, errFn func(http.ResponseWriter, *http.Request, ...error)) func(http.ResponseWriter, *http.Request) { 18 | const cacheTime = 8766 * time.Hour 19 | 20 | assetContents := make(map[string][]byte) 21 | mime.AddExtensionType(".ico", "image/vnd.microsoft.icon") 22 | mime.AddExtensionType(".txt", "text/plain; charset=utf-8") 23 | return func(w http.ResponseWriter, r *http.Request) { 24 | asset := r.RequestURI 25 | mimeType := mime.TypeByExtension(filepath.Ext(asset)) 26 | 27 | buf, ok := assetContents[asset] 28 | if !ok { 29 | cont, err := fs.ReadFile(s, asset) 30 | if err != nil { 31 | if errors.Is(err, os.ErrNotExist) { 32 | err = errors.NewNotFound(err, asset) 33 | } 34 | errFn(w, r, err) 35 | return 36 | } 37 | buf = cont 38 | } 39 | assetContents[asset] = buf 40 | 41 | w.Header().Set("Cache-Control", fmt.Sprintf("public,max-age=%d", int(cacheTime.Seconds()))) 42 | if mimeType != "" { 43 | w.Header().Set("Content-Type", mimeType) 44 | } 45 | w.Write(buf) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/assets/cmd/minify.go: -------------------------------------------------------------------------------- 1 | //go:build !dev 2 | 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "flag" 8 | "fmt" 9 | "log" 10 | "mime" 11 | "os" 12 | "path/filepath" 13 | "regexp" 14 | "strings" 15 | 16 | "git.sr.ht/~mariusor/assets" 17 | "github.com/tdewolff/minify" 18 | "github.com/tdewolff/minify/css" 19 | "github.com/tdewolff/minify/js" 20 | "github.com/tdewolff/minify/svg" 21 | ) 22 | 23 | var ( 24 | flagInput = flag.String("glob", "public/*", "") 25 | flagOutput = flag.String("o", "", "") 26 | flagVariable = flag.String("var", "br", "") 27 | flagBuild = flag.String("build", "", "") 28 | flagPackageName = flag.String("package", "assets", "") 29 | ) 30 | 31 | const ( 32 | constInput = "assets" 33 | ) 34 | 35 | const help = `Usage: minify [options] 36 | 37 | Minify uses gzip compression and minification to embed a virtual file system in the Go executables. 38 | 39 | Options: 40 | -glob folder/*[,folder1/*/*.ext] 41 | The glob paths to scan for files. It uses the path.Match patterns. Defaults to public/* 42 | -o 43 | Name of the generated file, follows input by default. 44 | -var assets 45 | Name of the exposed variable, "assets" by default. 46 | -build "linux,386 darwin,!cgo" 47 | Compiler build tags for the generated file, none by default. 48 | -package "assets" 49 | The package for the generated file. 50 | 51 | Generate a minify.gen.go file with the variable minify: 52 | //go:generate minify -glob assets/* -var minify 53 | ` 54 | 55 | var goIdentifier = regexp.MustCompile(`^\p{L}[\p{L}0-9_]*$`) 56 | 57 | func main() { 58 | log.SetFlags(0) 59 | log.SetPrefix("minify: ") 60 | flag.Usage = func() { 61 | fmt.Fprint(os.Stderr, help) 62 | } 63 | 64 | flag.Parse() 65 | if len(os.Args) <= 1 { 66 | flag.Usage() 67 | return 68 | } 69 | bp, err := os.Getwd() 70 | if err != nil { 71 | log.Panicf("unable to determine current path: %s", err) 72 | } 73 | log.Printf("starting in %s", bp) 74 | 75 | var inputs []string 76 | if flagInput == nil { 77 | inputs = []string{constInput} 78 | } else { 79 | inputs = strings.Split(*flagInput, ",") 80 | } 81 | 82 | output := *flagOutput 83 | if output == "" { 84 | output = strings.TrimLeft(inputs[0], "../") 85 | } 86 | if !strings.HasSuffix(output, ".gen.go") { 87 | output = strings.Split(output, ".")[0] + ".gen.go" 88 | } 89 | 90 | variable := *flagVariable 91 | if !goIdentifier.MatchString(variable) { 92 | log.Fatalln(variable, "is not a valid Go identifier") 93 | } 94 | 95 | stripAssetPrefix := func(f *assets.File) error { 96 | f.Fpath = strings.TrimPrefix(f.Fpath, "assets/") 97 | return nil 98 | } 99 | 100 | bundle, err := assets.Glob(inputs...).Pack(stripAssetPrefix, minifier().pack) 101 | if err != nil { 102 | log.Fatal(err) 103 | } 104 | 105 | code, err := assets.GenerateCode(*flagPackageName, variable, *flagBuild, bundle) 106 | if err != nil { 107 | log.Fatalf("could not buildFiles file: %v\n", err) 108 | } 109 | outputPath, err := os.Getwd() 110 | if err != nil { 111 | log.Fatalf("could not write to %s: %v\n", output, err) 112 | } 113 | output = filepath.Clean(filepath.Join(outputPath, output)) 114 | if err = os.WriteFile(output, code, 0644); err != nil { 115 | log.Fatalf("could not write to %s: %v\n", output, err) 116 | } 117 | } 118 | 119 | type m struct { 120 | *minify.M 121 | } 122 | 123 | func minifier() *m { 124 | m := new(m) 125 | m.M = minify.New() 126 | m.AddFunc("image/svg+xml", svg.Minify) 127 | m.AddFunc("text/css", css.Minify) 128 | m.AddFuncRegexp(regexp.MustCompile("^(application|text)/(x-)?(java|ecma)script$"), js.Minify) 129 | return m 130 | } 131 | 132 | func (m *m) pack(f *assets.File) error { 133 | ext := filepath.Ext(f.Fpath) 134 | if !(ext == ".css" || ext == ".js" || ext == ".svg") { 135 | return nil 136 | } 137 | o := bytes.Buffer{} 138 | if err := m.Minify(mime.TypeByExtension(ext), &o, bytes.NewBuffer(f.Data)); err != nil { 139 | return err 140 | } 141 | log.Printf("minified file: %s", f.Fpath) 142 | f.Data = o.Bytes() 143 | f.Fsize = int64(o.Len()) 144 | return nil 145 | } 146 | -------------------------------------------------------------------------------- /internal/config/env.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "strings" 4 | 5 | // EnvType type alias 6 | type EnvType string 7 | 8 | const ( 9 | // DEV environment 10 | DEV EnvType = "dev" 11 | // PROD environment 12 | PROD EnvType = "prod" 13 | // QA environment 14 | QA EnvType = "qa" 15 | // testing environment 16 | TEST EnvType = "test" 17 | ) 18 | 19 | var ( 20 | Default = Configuration{Env: DEV} 21 | 22 | validEnvTypes = []EnvType{ 23 | DEV, 24 | PROD, 25 | QA, 26 | TEST, 27 | } 28 | ) 29 | 30 | func ValidEnv(env EnvType) bool { 31 | if len(env) == 0 { 32 | return false 33 | } 34 | s := strings.ToLower(string(env)) 35 | for _, k := range validEnvTypes { 36 | if strings.Contains(s, string(k)) { 37 | return true 38 | } 39 | } 40 | return false 41 | } 42 | 43 | func (e EnvType) IsProd() bool { 44 | return strings.Contains(string(e), string(PROD)) 45 | } 46 | 47 | func (e EnvType) IsQA() bool { 48 | return strings.Contains(string(e), string(QA)) 49 | } 50 | 51 | func (e EnvType) IsTest() bool { 52 | return strings.Contains(string(e), string(TEST)) 53 | } 54 | 55 | func (e EnvType) IsDev() bool { 56 | return strings.Contains(string(e), string(DEV)) || strings.Contains(string(e), string(TEST)) 57 | } 58 | -------------------------------------------------------------------------------- /items.go: -------------------------------------------------------------------------------- 1 | package brutalinks 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/url" 7 | "time" 8 | 9 | vocab "github.com/go-ap/activitypub" 10 | "github.com/go-ap/errors" 11 | "github.com/go-chi/chi/v5" 12 | ) 13 | 14 | type ItemMetadata struct { 15 | To AccountCollection `json:"to,omitempty"` 16 | CC AccountCollection `json:"to,omitempty"` 17 | Tags TagCollection `json:"tags,omitempty"` 18 | Mentions TagCollection `json:"mentions,omitempty"` 19 | ID string `json:"id,omitempty"` 20 | URL string `json:"url,omitempty"` 21 | RepliesURI string `json:"replies,omitempty"` 22 | LikesURI string `json:"likes,omitempty"` 23 | SharesURI string `json:"shares,omitempty"` 24 | AuthorURI string `json:"author,omitempty"` 25 | Icon ImageMetadata `json:"icon,omitempty"` 26 | } 27 | 28 | var ValidContentTypes = vocab.ActivityVocabularyTypes{ 29 | vocab.ArticleType, 30 | vocab.NoteType, 31 | vocab.LinkType, 32 | vocab.PageType, 33 | vocab.DocumentType, 34 | vocab.VideoType, 35 | vocab.AudioType, 36 | } 37 | 38 | var ValidContentManagementTypes = vocab.ActivityVocabularyTypes{ 39 | vocab.UpdateType, 40 | vocab.CreateType, 41 | vocab.DeleteType, 42 | } 43 | 44 | type Identifiable interface { 45 | Id() int64 46 | } 47 | 48 | func (i *Item) IsValid() bool { 49 | return i != nil && i.Hash.IsValid() 50 | } 51 | 52 | // AP returns the underlying actvitypub item 53 | func (i *Item) AP() vocab.Item { 54 | return i.Pub 55 | } 56 | 57 | // Content returns the content of the Item 58 | func (i Item) Content() map[string][]byte { 59 | return map[string][]byte{i.MimeType: []byte(i.Data)} 60 | } 61 | 62 | // Tags returns the tags associated with the current Item 63 | func (i Item) Tags() TagCollection { 64 | return i.Metadata.Tags 65 | } 66 | 67 | // Mentions returns the mentions associated with the current Item 68 | func (i Item) Mentions() TagCollection { 69 | return i.Metadata.Mentions 70 | } 71 | 72 | func (i *Item) Deleted() bool { 73 | return i != nil && (i.Flags&FlagsDeleted) == FlagsDeleted 74 | } 75 | 76 | // UnDelete remove the deleted flag from an item 77 | func (i *Item) UnDelete() { 78 | i.Flags ^= FlagsDeleted 79 | } 80 | 81 | // Delete add the deleted flag on an item 82 | func (i *Item) Delete() { 83 | i.Flags |= FlagsDeleted 84 | } 85 | 86 | func (i *Item) Private() bool { 87 | return i != nil && (i.Flags&FlagsPrivate) == FlagsPrivate 88 | } 89 | 90 | func (i *Item) Public() bool { 91 | return i != nil && (i.Flags&FlagsPrivate) != FlagsPrivate 92 | } 93 | 94 | func (i *Item) MakePrivate() { 95 | i.Flags |= FlagsPrivate 96 | } 97 | 98 | func (i *Item) MakePublic() { 99 | i.Flags ^= FlagsPrivate 100 | } 101 | 102 | func (i Item) IsLink() bool { 103 | return isDocument(i.MimeType) 104 | } 105 | 106 | func (i Item) IsSelf() bool { 107 | return !isDocument(i.MimeType) 108 | } 109 | 110 | func (i ItemCollection) First() (*Item, error) { 111 | for _, it := range i { 112 | return &it, nil 113 | } 114 | return nil, errors.Errorf("empty %T", i) 115 | } 116 | 117 | func (i ItemCollection) Split(pieceCount int) []ItemCollection { 118 | l := len(i) 119 | if l <= pieceCount { 120 | return []ItemCollection{i} 121 | } 122 | ret := make([]ItemCollection, 0) 123 | for it := 0; it <= l/pieceCount; it++ { 124 | st := it * pieceCount 125 | if st > l { 126 | break 127 | } 128 | end := (it + 1) * pieceCount 129 | if end > l { 130 | end = l 131 | } 132 | ret = append(ret, i[st:end]) 133 | } 134 | return ret 135 | } 136 | 137 | const ( 138 | MaxContentItems = 35 139 | ) 140 | 141 | func detectMimeType(data string) string { 142 | u, err := url.ParseRequestURI(data) 143 | if err == nil && u != nil && !bytes.ContainsRune([]byte(data), '\n') { 144 | return MimeTypeURL 145 | } 146 | return "text/plain" 147 | } 148 | 149 | func updateItemFromRequest(r *http.Request, author Account, i *Item) error { 150 | if r.Method != http.MethodPost { 151 | return errors.Errorf("invalid http method type") 152 | } 153 | 154 | var receivers AccountCollection 155 | var err error 156 | 157 | if i.Metadata == nil { 158 | i.Metadata = new(ItemMetadata) 159 | } 160 | if hash := HashFromString(r.PostFormValue("hash")); hash.IsValid() { 161 | i.Hash = hash 162 | } 163 | if receivers, err = accountsFromRequestHandle(r); err == nil && chi.URLParam(r, "hash") == "" { 164 | i.MakePrivate() 165 | for _, rec := range receivers { 166 | if !rec.IsValid() { 167 | continue 168 | } 169 | i.Metadata.To = append(i.Metadata.To, rec) 170 | } 171 | } 172 | if tit := r.PostFormValue("title"); len(tit) > 0 { 173 | i.Title = tit 174 | } 175 | if dat := r.PostFormValue("data"); len(dat) > 0 { 176 | i.Data = dat 177 | } 178 | 179 | i.SubmittedBy = &author 180 | i.MimeType = detectMimeType(i.Data) 181 | 182 | i.Metadata.Tags, i.Metadata.Mentions = loadTags(i.Data) 183 | if !i.IsLink() { 184 | i.MimeType = r.PostFormValue("mime-type") 185 | } 186 | if len(i.Data) > 0 { 187 | now := time.Now().UTC() 188 | i.SubmittedAt = now 189 | i.UpdatedAt = now 190 | } 191 | if parent := HashFromString(r.PostFormValue("parent")); parent.IsValid() { 192 | if i.Parent == nil || i.Parent.ID() != parent { 193 | i.Parent = &Item{Hash: parent} 194 | } 195 | } 196 | if op := HashFromString(r.PostFormValue("op")); op.IsValid() { 197 | if i.OP != nil || i.OP.ID() != op { 198 | i.OP = &Item{Hash: op} 199 | } 200 | } 201 | return nil 202 | } 203 | 204 | func ContentFromRequest(r *http.Request, author Account) (Item, error) { 205 | i := Item{} 206 | return i, updateItemFromRequest(r, author, &i) 207 | } 208 | -------------------------------------------------------------------------------- /loader.go: -------------------------------------------------------------------------------- 1 | package brutalinks 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type CtxtKey string 8 | 9 | var ( 10 | LoggedAccountCtxtKey CtxtKey = "__acct" 11 | RepositoryCtxtKey CtxtKey = "__repository" 12 | FilterCtxtKey CtxtKey = "__filter" 13 | ModelCtxtKey CtxtKey = "__model" 14 | AuthorCtxtKey CtxtKey = "__author" 15 | CursorCtxtKey CtxtKey = "__cursor" 16 | ContentCtxtKey CtxtKey = "__content" 17 | DependenciesCtxtKey CtxtKey = "__deps" 18 | ) 19 | 20 | type WebInfo struct { 21 | Title string `json:"title"` 22 | Email string `json:"email"` 23 | Summary string `json:"summary"` 24 | Description string `json:"description"` 25 | Thumbnail string `json:"thumbnail,omitempty"` 26 | Languages []string `json:"languages"` 27 | URI string `json:"uri"` 28 | Urls []string `json:"urls,omitempty"` 29 | Version string `json:"version"` 30 | } 31 | 32 | func ContextModel(ctx context.Context) Model { 33 | var m Model 34 | m, _ = ctx.Value(ModelCtxtKey).(Model) 35 | return m 36 | } 37 | 38 | func ContextListingModel(ctx context.Context) *listingModel { 39 | var m *listingModel 40 | m, _ = ctx.Value(ModelCtxtKey).(*listingModel) 41 | return m 42 | } 43 | 44 | func ContextContentModel(ctx context.Context) *contentModel { 45 | var m *contentModel 46 | m, _ = ctx.Value(ModelCtxtKey).(*contentModel) 47 | return m 48 | } 49 | 50 | func ContextModerationModel(ctx context.Context) *moderationModel { 51 | var m *moderationModel 52 | m, _ = ctx.Value(ModelCtxtKey).(*moderationModel) 53 | return m 54 | } 55 | 56 | func ContextRepository(ctx context.Context) *repository { 57 | if r, ok := ctx.Value(RepositoryCtxtKey).(*repository); ok { 58 | return r 59 | } 60 | return nil 61 | } 62 | 63 | func ContextAccount(ctx context.Context) *Account { 64 | if a, ok := ctx.Value(LoggedAccountCtxtKey).(*Account); ok { 65 | return a 66 | } 67 | return nil 68 | } 69 | 70 | func ContextAuthors(ctx context.Context) AccountCollection { 71 | if a, ok := ctx.Value(AuthorCtxtKey).(AccountCollection); ok { 72 | return a 73 | } 74 | return nil 75 | } 76 | 77 | func ContextCursor(ctx context.Context) *Cursor { 78 | if c, ok := ctx.Value(CursorCtxtKey).(*Cursor); ok { 79 | return c 80 | } 81 | return nil 82 | } 83 | 84 | func ContextItem(ctx context.Context) *Item { 85 | cont := ctx.Value(ContentCtxtKey) 86 | if i, ok := cont.(*Item); ok { 87 | return i 88 | } 89 | if c := ContextCursor(ctx); c != nil { 90 | for _, it := range c.items { 91 | if i, ok := it.(*Item); ok { 92 | return i 93 | } 94 | } 95 | } 96 | return nil 97 | } 98 | 99 | func ContextRegisterModel(ctx context.Context) *registerModel { 100 | if r, ok := ctx.Value(ModelCtxtKey).(*registerModel); ok { 101 | return r 102 | } 103 | return nil 104 | } 105 | 106 | func ContextDependentLoads(ctx context.Context) *deps { 107 | if r, ok := ctx.Value(DependenciesCtxtKey).(*deps); ok { 108 | return r 109 | } 110 | return nil 111 | } 112 | -------------------------------------------------------------------------------- /repository_cache.go: -------------------------------------------------------------------------------- 1 | package brutalinks 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | vocab "github.com/go-ap/activitypub" 7 | "github.com/go-ap/cache" 8 | ) 9 | 10 | func caches(enabled bool) *cc { 11 | c := cache.New(enabled) 12 | return &cc{c} 13 | } 14 | 15 | type cc struct { 16 | c cache.CanStore 17 | } 18 | 19 | func accum(toRemove *vocab.IRIs, iri vocab.IRI, col vocab.CollectionPath) { 20 | if repl := col.IRI(iri); !toRemove.Contains(repl) { 21 | *toRemove = append(*toRemove, repl) 22 | } 23 | } 24 | 25 | func accumItem(it vocab.Item, toRemove *vocab.IRIs, col vocab.CollectionPath) { 26 | if vocab.IsNil(it) { 27 | return 28 | } 29 | if vocab.IsItemCollection(it) { 30 | vocab.OnItemCollection(it, func(c *vocab.ItemCollection) error { 31 | for _, ob := range c.Collection() { 32 | accum(toRemove, ob.GetLink(), col) 33 | } 34 | return nil 35 | }) 36 | } else { 37 | accum(toRemove, it.GetLink(), col) 38 | } 39 | } 40 | 41 | func (c *cc) removeRelated(items ...vocab.Item) { 42 | toRemove := make(vocab.IRIs, 0) 43 | for _, it := range items { 44 | if vocab.IsNil(it) { 45 | continue 46 | } 47 | if vocab.IsObject(it) || vocab.IsItemCollection(it) && len(it.GetLink()) > 0 { 48 | typ := it.GetType() 49 | if vocab.ActivityTypes.Contains(typ) || vocab.IntransitiveActivityTypes.Contains(typ) { 50 | vocab.OnActivity(it, c.accumActivityIRIs(&toRemove)) 51 | } else { 52 | vocab.OnObject(it, c.accumObjectIRIs(&toRemove)) 53 | } 54 | } 55 | 56 | if aIRI := it.GetLink(); len(aIRI) > 0 && !toRemove.Contains(aIRI) { 57 | toRemove = append(toRemove, aIRI) 58 | } 59 | } 60 | c.remove(toRemove...) 61 | } 62 | 63 | func (c *cc) accumRecipientIRIs(r vocab.Item, toRemove *vocab.IRIs) { 64 | iri := r.GetLink() 65 | if iri.Equals(vocab.PublicNS, false) { 66 | return 67 | } 68 | 69 | _, col := vocab.Split(iri) 70 | 71 | toDeref := vocab.CollectionPaths{vocab.Followers, vocab.Following} 72 | if toDeref.Contains(col) { 73 | if iris := c.get(iri); !vocab.IsNil(iris) { 74 | vocab.OnCollectionIntf(iris, func(col vocab.CollectionInterface) error { 75 | for _, it := range col.Collection() { 76 | accumItem(it.GetLink(), toRemove, vocab.Outbox) 77 | } 78 | return nil 79 | }) 80 | } 81 | return 82 | } 83 | toAppend := vocab.CollectionPaths{vocab.Inbox, vocab.Outbox} 84 | if toAppend.Contains(col) { 85 | if toRemove.Contains(iri) { 86 | *toRemove = append(*toRemove, iri) 87 | } 88 | return 89 | } 90 | accumItem(r, toRemove, vocab.Inbox) 91 | } 92 | 93 | func (c *cc) accumActivityIRIs(toRemove *vocab.IRIs) func(activity *vocab.Activity) error { 94 | return func(a *vocab.Activity) error { 95 | for _, r := range a.Recipients() { 96 | c.accumRecipientIRIs(r, toRemove) 97 | } 98 | if destCol := vocab.Outbox.IRI(a.Actor); !toRemove.Contains(destCol) { 99 | *toRemove = append(*toRemove, destCol) 100 | } 101 | typ := a.Type 102 | withSideEffects := vocab.ActivityVocabularyTypes{vocab.UpdateType, vocab.UndoType, vocab.DeleteType} 103 | if withSideEffects.Contains(typ) { 104 | base := filepath.Dir(a.Object.GetLink().String()) 105 | *toRemove = append(*toRemove, vocab.IRI(base), a.Object.GetLink()) 106 | } 107 | return vocab.OnObject(a.Object, c.accumObjectIRIs(toRemove)) 108 | } 109 | } 110 | 111 | func (c *cc) accumObjectIRIs(toRemove *vocab.IRIs) func(*vocab.Object) error { 112 | return func(ob *vocab.Object) error { 113 | if ob == nil { 114 | return nil 115 | } 116 | if !ob.IsObject() { 117 | return nil 118 | } 119 | if obIRI := ob.GetLink(); len(obIRI) > 0 && !toRemove.Contains(obIRI) { 120 | *toRemove = append(*toRemove, obIRI) 121 | } 122 | for _, r := range ob.Recipients() { 123 | c.accumRecipientIRIs(r, toRemove) 124 | } 125 | accumItem(ob.InReplyTo, toRemove, vocab.Replies) 126 | accumItem(ob.AttributedTo, toRemove, vocab.Outbox) 127 | return nil 128 | } 129 | } 130 | 131 | func (c *cc) remove(iris ...vocab.IRI) { 132 | if len(iris) == 0 { 133 | return 134 | } 135 | c.c.Delete(iris...) 136 | } 137 | 138 | func (c *cc) add(iri vocab.IRI, it vocab.Item) { 139 | c.c.Store(iri, it) 140 | } 141 | 142 | func (c *cc) get(iri vocab.IRI) vocab.Item { 143 | return c.c.Load(iri) 144 | } 145 | -------------------------------------------------------------------------------- /repository_cache_test.go: -------------------------------------------------------------------------------- 1 | package brutalinks 2 | 3 | import ( 4 | "testing" 5 | 6 | vocab "github.com/go-ap/activitypub" 7 | "github.com/go-ap/cache" 8 | ) 9 | 10 | func Test_cacheAddGet(t *testing.T) { 11 | type args struct { 12 | k vocab.IRI 13 | v vocab.Item 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | }{ 19 | { 20 | "test", 21 | args{ 22 | vocab.IRI("test"), 23 | new(vocab.Object), 24 | }, 25 | }, 26 | } 27 | 28 | c := cache.New(true) 29 | 30 | for _, tt := range tests { 31 | t.Run(tt.name, func(t *testing.T) { 32 | c.Store(tt.args.k, tt.args.v) 33 | 34 | if v := c.Load(tt.args.k); v != tt.args.v { 35 | t.Errorf("Value retrieved is different: %v, expected %v", v, tt.args.v) 36 | } 37 | 38 | if vv := c.Load(tt.args.k); vv != tt.args.v { 39 | t.Errorf("Value the getter retrieved is different: %v, expected %v", vv, tt.args.v) 40 | } 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /sessions.go: -------------------------------------------------------------------------------- 1 | package brutalinks 2 | 3 | import ( 4 | "encoding/gob" 5 | "net/http" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "git.sr.ht/~mariusor/brutalinks/internal/config" 11 | log "git.sr.ht/~mariusor/lw" 12 | "git.sr.ht/~mariusor/mask" 13 | vocab "github.com/go-ap/activitypub" 14 | "github.com/go-ap/errors" 15 | "github.com/gorilla/sessions" 16 | ) 17 | 18 | type flashType string 19 | 20 | const ( 21 | Success flashType = "success" 22 | Info flashType = "info" 23 | Warning flashType = "warning" 24 | Error flashType = "error" 25 | ) 26 | 27 | type flash struct { 28 | Type flashType 29 | Msg string 30 | } 31 | 32 | type sess struct { 33 | enabled bool 34 | path string 35 | name string 36 | s sessions.Store 37 | infoFn CtxLogFn 38 | errFn CtxLogFn 39 | } 40 | 41 | func initSession(c appConfig, infoFn, errFn CtxLogFn) (sess, error) { 42 | // session encoding for account and flash message objects 43 | gob.Register(Account{}) 44 | gob.Register(flash{}) 45 | gob.Register(vocab.Activity{}) 46 | gob.Register(vocab.IRI("")) 47 | gob.Register(vocab.NaturalLanguageValues{}) 48 | gob.Register(vocab.Object{}) 49 | gob.Register(vocab.Actor{}) 50 | gob.Register(vocab.ItemCollection{}) 51 | gob.Register(vocab.Link{}) 52 | gob.Register(vocab.Tombstone{}) 53 | 54 | if len(c.SessionKeys) == 0 { 55 | c.SessionsEnabled = false 56 | return sess{}, errors.NotImplementedf("no session encryption keys, unable to use sessions") 57 | } 58 | s := sess{ 59 | name: sessionName, 60 | enabled: c.SessionsEnabled, 61 | infoFn: infoFn, 62 | errFn: errFn, 63 | } 64 | 65 | var err error 66 | switch strings.ToLower(c.SessionsBackend) { 67 | case config.SessionsCookieBackend: 68 | s.s, err = initCookieSession(c, infoFn, errFn) 69 | case config.SessionsFSBackend: 70 | fallthrough 71 | default: 72 | if strings.ToLower(c.SessionsBackend) != config.SessionsFSBackend { 73 | infoFn(log.Ctx{"backend": c.SessionsBackend})("Invalid session backend, falling back to %s.", config.SessionsFSBackend) 74 | c.SessionsBackend = config.SessionsFSBackend 75 | } 76 | s.path = filepath.Clean(filepath.Join(c.SessionsPath, string(c.Env), c.HostName)) 77 | s.s, err = initFileSession(c, s.path, infoFn, errFn) 78 | } 79 | if err != nil { 80 | s.enabled = false 81 | } 82 | return s, nil 83 | } 84 | 85 | func maskSessionKeys(keys ...[]byte) []string { 86 | hidden := make([]string, len(keys)) 87 | for i, k := range keys { 88 | hidden[i] = mask.B(k).String() 89 | } 90 | return hidden 91 | } 92 | 93 | func initCookieSession(c appConfig, infoFn, errFn CtxLogFn) (sessions.Store, error) { 94 | ss := sessions.NewCookieStore(c.SessionKeys...) 95 | ss.Options.Path = "/" 96 | ss.Options.HttpOnly = true 97 | ss.Options.Secure = c.Secure 98 | ss.Options.SameSite = http.SameSiteLaxMode 99 | ss.Options.Domain = c.HostName 100 | 101 | infoFn(log.Ctx{ 102 | "type": c.SessionsBackend, 103 | "env": c.Env, 104 | "keys": maskSessionKeys(c.SessionKeys...), 105 | "domain": c.HostName, 106 | })("Session settings") 107 | if !c.Env.IsDev() { 108 | ss.Options.SameSite = http.SameSiteStrictMode 109 | } 110 | return ss, nil 111 | } 112 | 113 | func makeSessionsPath(path string) error { 114 | err := os.MkdirAll(path, 0700) 115 | if err != nil { 116 | return err 117 | } 118 | return nil 119 | } 120 | 121 | func initFileSession(c appConfig, path string, infoFn, errFn CtxLogFn) (sessions.Store, error) { 122 | if _, err := os.Stat(path); err != nil && os.IsNotExist(err) { 123 | if err := makeSessionsPath(path); err != nil { 124 | return nil, err 125 | } 126 | } 127 | f, err := os.Open(path) 128 | if err != nil { 129 | return nil, err 130 | } 131 | f.Close() 132 | infoFn(log.Ctx{ 133 | "type": c.SessionsBackend, 134 | "env": c.Env, 135 | "path": path, 136 | "keys": maskSessionKeys(c.SessionKeys...), 137 | "hostname": c.HostName, 138 | })("Session settings") 139 | ss := sessions.NewFilesystemStore(path, c.SessionKeys...) 140 | ss.Options.Path = "/" 141 | ss.Options.HttpOnly = true 142 | ss.Options.Secure = c.Secure 143 | ss.Options.SameSite = http.SameSiteLaxMode 144 | if c.Env.IsProd() { 145 | ss.Options.Domain = c.HostName 146 | ss.Options.SameSite = http.SameSiteStrictMode 147 | } 148 | ss.MaxLength(1 << 24) 149 | return ss, nil 150 | } 151 | 152 | func (s *sess) clear(w http.ResponseWriter, r *http.Request) error { 153 | if !s.enabled { 154 | return nil 155 | } 156 | if s.s == nil { 157 | return errors.Newf("invalid session") 158 | } 159 | ss, _ := s.s.Get(r, s.name) 160 | ss.Options.MaxAge = -1 161 | http.SetCookie(w, sessions.NewCookie(ss.Name(), "", ss.Options)) 162 | return nil 163 | } 164 | 165 | func (s *sess) get(w http.ResponseWriter, r *http.Request) (*sessions.Session, error) { 166 | if !s.enabled { 167 | return nil, nil 168 | } 169 | if s.s == nil { 170 | return nil, errors.Newf("invalid session") 171 | } 172 | ss, err := s.s.Get(r, s.name) 173 | if os.IsNotExist(err) { 174 | err = nil 175 | } 176 | return ss, err 177 | } 178 | 179 | func (s *sess) save(w http.ResponseWriter, r *http.Request) error { 180 | if !s.enabled || s.s == nil { 181 | s.clear(w, r) 182 | return nil 183 | } 184 | ss, err := s.s.Get(r, s.name) 185 | if err != nil { 186 | s.clear(w, r) 187 | } 188 | if len(ss.Values) > 0 || len(ss.Flashes()) > 0 { 189 | return ss.Save(r, w) 190 | } 191 | return nil 192 | } 193 | 194 | func (s *sess) addFlashMessages(typ flashType, w http.ResponseWriter, r *http.Request, msgs ...string) { 195 | ss, _ := s.get(w, r) 196 | for _, msg := range msgs { 197 | n := flash{typ, msg} 198 | ss.AddFlash(n) 199 | } 200 | } 201 | 202 | func (s *sess) loadFlashMessages(w http.ResponseWriter, r *http.Request) (func() []flash, error) { 203 | var flashData []flash 204 | flashFn := func() []flash { 205 | return flashData 206 | } 207 | 208 | ss, err := s.get(w, r) 209 | if err != nil || ss == nil { 210 | return flashFn, nil 211 | } 212 | flashes := ss.Flashes() 213 | // setting the local flashData value 214 | for _, int := range flashes { 215 | if int == nil { 216 | continue 217 | } 218 | if f, ok := int.(flash); ok { 219 | flashData = append(flashData, f) 220 | } 221 | } 222 | // NOTE(marius): this last save is used to ensure the flash messages are removed between page loads 223 | // There should be a better way to achieve this. 224 | return flashFn, ss.Save(r, w) 225 | } 226 | -------------------------------------------------------------------------------- /templates/404.html: -------------------------------------------------------------------------------- 1 |
Path {{.path}}
was not found on the server.
{{$error.Error | ToTitle }}
4 | {{end}} 5 | -------------------------------------------------------------------------------- /templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ template "partials/head" . -}} 6 | 7 | {{- $account := CurrentAccount }} 8 | 9 |There's only dust here.
11 | {{ end -}} 12 | -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 | {{template "partials/login/remote-login" . }} 2 | -------------------------------------------------------------------------------- /templates/moderate.html: -------------------------------------------------------------------------------- 1 | {{- if IsComment .Content.Object }} 2 |You received a follow request from {{ .SubmittedBy | ShowAccountHandle }}
3 | {{- else -}} 4 |{{ .SubmittedBy | ShowAccountHandle }} sent a follow request to {{ .Object | ShowAccountHandle }}
5 | {{- end }} 6 | -------------------------------------------------------------------------------- /templates/partials/follow/meta.html: -------------------------------------------------------------------------------- 1 | {{- $it := . -}} 2 | 5 | -------------------------------------------------------------------------------- /templates/partials/follow/score.html: -------------------------------------------------------------------------------- 1 | {{- $readonly := IsReadOnly . -}} 2 | 8 | -------------------------------------------------------------------------------- /templates/partials/footer.html: -------------------------------------------------------------------------------- 1 | {{ if and (CanPaginate .) -}} 2 | {{ if (or (ne .PrevPage "") (ne .NextPage "")) }} 3 | 13 | {{ end -}} 14 | {{ end -}} 15 | {{ $version := Version -}} 16 |The :first-of-type selector in CSS allows you to target the first occurence of an element within its container. It is defined in the CSS Selectors Level 3 spec as a “structural pseudo-class”, meaning it is used to style content based on its relationship with parent and sibling content.
\nSuppose we have an article with a title and several paragraphs:
\nUsing :first-of-type is very similar to :nth-child but with one critical difference: it is less specific. In the example above, if we had used p:nth-child(1), nothing would happen because the paragraph is not the first child of its parent (the <article>). This reveals the power of :first-of-type: it targets a particular type of element in a particular arrangement with relation to similar siblings, not all siblings.
\nThe more complete example below demonstrates the use of :first-of-type and a related pseudo-class selector, :last-of-type.
\nfrom here
\n","attributedTo":"https://fedbox/actors/03bdf89a-9c59-481c-865c-be0316d810e7","context":"https://fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5","inReplyTo":"https://fedbox/objects/a0f5c056-0671-4e87-846b-56577658c79f","replies":"https://fedbox/objects/0a99b75d-05a0-439e-9c09-d04a33b78de8/replies","url":"/~admin/0a99b75d-05a0-439e-9c09-d04a33b78de8","to":["https://www.w3.org/ns/activitystreams#Public","https://fedbox/actors/03bdf89a-9c59-481c-865c-be0316d810e7"],"cc":["https://fedbox/actors/c4cdfe54-9919-4dd4-8a71-63beafe12b8c"],"published":"2022-03-25T09:21:34Z","updated":"2022-03-25T09:31:14Z","likes":"https://fedbox/objects/0a99b75d-05a0-439e-9c09-d04a33b78de8/likes","shares":"https://fedbox/objects/0a99b75d-05a0-439e-9c09-d04a33b78de8/shares","source":{"mediaType":"text/markdown","content":"The :first-of-type selector in CSS allows you to target the first occurence of an element within its container. It is defined in the CSS Selectors Level 3 spec as a “structural pseudo-class”, meaning it is used to style content based on its relationship with parent and sibling content.\r\n\r\nSuppose we have an article with a title and several paragraphs:\r\n\r\nUsing :first-of-type is very similar to :nth-child but with one critical difference: it is less specific. In the example above, if we had used p:nth-child(1), nothing would happen because the paragraph is not the first child of its parent (the <article>). This reveals the power of :first-of-type: it targets a particular type of element in a particular arrangement with relation to similar siblings, not all siblings.\r\n\r\nThe more complete example below demonstrates the use of :first-of-type and a related pseudo-class selector, :last-of-type.\r\n\r\nfrom [here](https://css-tricks.com/almanac/selectors/f/first-of-type/)"}} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/0a99b75d-05a0-439e-9c09-d04a33b78de8/likes/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/0a99b75d-05a0-439e-9c09-d04a33b78de8/likes","type":"OrderedCollection","generator":"https://fedbox/","published":"2023-03-21T16:31:02Z","totalItems":1} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/0a99b75d-05a0-439e-9c09-d04a33b78de8/likes/bb56def4-ddaf-4de1-8df3-a8bab43bbd36: -------------------------------------------------------------------------------- 1 | ../../../activities/bb56def4-ddaf-4de1-8df3-a8bab43bbd36 -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/1b1e6889-5081-489c-8a67-d103f504b90b/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/1b1e6889-5081-489c-8a67-d103f504b90b","name":"#spam","url":"https://brutalinks/t/spam","to":["https://www.w3.org/ns/activitystreams#Public"]} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/2a6ffab9-39be-4ae6-ae1b-aa625fd0fb70/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/2a6ffab9-39be-4ae6-ae1b-aa625fd0fb70","type":"Tombstone","to":["https://www.w3.org/ns/activitystreams#Public"],"formerType":"Note","deleted":"2022-03-24T16:10:09Z"} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/2a6ffab9-39be-4ae6-ae1b-aa625fd0fb70/likes/53ad3db1-1826-4169-8c03-49c6366a2cae: -------------------------------------------------------------------------------- 1 | ../../../activities/53ad3db1-1826-4169-8c03-49c6366a2cae -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/2a6ffab9-39be-4ae6-ae1b-aa625fd0fb70/likes/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/2a6ffab9-39be-4ae6-ae1b-aa625fd0fb70/likes","type":"OrderedCollection","generator":"https://fedbox/","published":"2023-03-21T16:31:02Z","totalItems":1} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/7c4202a4-78cf-44fa-be2a-0544da4968ea/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/7c4202a4-78cf-44fa-be2a-0544da4968ea","name":"#tags","url":"https://brutalinks/t/tags","to":["https://www.w3.org/ns/activitystreams#Public"]} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/8ce864d2-deae-4b79-9537-2ed283ee4148/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/8ce864d2-deae-4b79-9537-2ed283ee4148","name":"#sysop","url":"https://brutalinks/t/sysop","to":["https://www.w3.org/ns/activitystreams#Public"]} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5","type":"Note","mediaType":"text/html","name":"Tag test","content":"Ehlo #tags
\n","attributedTo":"https://fedbox/actors/03bdf89a-9c59-481c-865c-be0316d810e7","replies":"https://fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5/replies","tag":[{"id":"https://fedbox/objects/7c4202a4-78cf-44fa-be2a-0544da4968ea","name":"#tags","url":"https://brutalinks/t/tags","to":["https://www.w3.org/ns/activitystreams#Public"]}],"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://fedbox/actors/03bdf89a-9c59-481c-865c-be0316d810e7/followers","https://fedbox/actors/c4cdfe54-9919-4dd4-8a71-63beafe12b8c","https://fedbox/actors/c4cdfe54-9919-4dd4-8a71-63beafe12b8c/followers"],"bcc":["https://fedbox/"],"published":"2022-02-25T16:47:16Z","updated":"2022-02-25T16:47:16Z","likes":"https://fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5/likes","shares":"https://fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5/shares","source":{"mediaType":"text/markdown","content":"Ehlo #tags"}} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5/likes/8aec0ec3-bd93-4020-ab81-a6957674d10d: -------------------------------------------------------------------------------- 1 | ../../../activities/8aec0ec3-bd93-4020-ab81-a6957674d10d -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5/likes/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5/likes","type":"OrderedCollection","generator":"https://fedbox/","published":"2023-03-21T16:31:02Z","totalItems":1} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5/replies/0a99b75d-05a0-439e-9c09-d04a33b78de8: -------------------------------------------------------------------------------- 1 | ../../0a99b75d-05a0-439e-9c09-d04a33b78de8 -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5/replies/2a6ffab9-39be-4ae6-ae1b-aa625fd0fb70: -------------------------------------------------------------------------------- 1 | ../../2a6ffab9-39be-4ae6-ae1b-aa625fd0fb70 -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5/replies/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5/replies","type":"OrderedCollection","generator":"https://fedbox/","published":"2023-03-21T16:31:02Z","totalItems":3} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5/replies/a0f5c056-0671-4e87-846b-56577658c79f: -------------------------------------------------------------------------------- 1 | ../../a0f5c056-0671-4e87-846b-56577658c79f -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/9f252ada-b25c-418e-a8d4-1efa21cb0361/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/9f252ada-b25c-418e-a8d4-1efa21cb0361","name":"#mod","summary":"Moderator tag for instance https://b804150cb032","attributedTo":"https://fedbox/actors/c4cdfe54-9919-4dd4-8a71-63beafe12b8c","replies":"https://fedbox/objects/9f252ada-b25c-418e-a8d4-1efa21cb0361/replies","url":"https://b804150cb032/t/mod","to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://fedbox/actors/c4cdfe54-9919-4dd4-8a71-63beafe12b8c","https://fedbox/actors/c4cdfe54-9919-4dd4-8a71-63beafe12b8c/followers"],"bcc":["https://fedbox/"],"published":"2023-06-02T10:08:19Z","likes":"https://fedbox/objects/9f252ada-b25c-418e-a8d4-1efa21cb0361/likes","shares":"https://fedbox/objects/9f252ada-b25c-418e-a8d4-1efa21cb0361/shares"} -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/9f252ada-b25c-418e-a8d4-1efa21cb0361/likes/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/9f252ada-b25c-418e-a8d4-1efa21cb0361/likes","type":"OrderedCollection","published":"2023-06-02T10:08:19Z","totalItems":0} -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/9f252ada-b25c-418e-a8d4-1efa21cb0361/replies/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/9f252ada-b25c-418e-a8d4-1efa21cb0361/replies","type":"OrderedCollection","published":"2023-06-02T10:08:19Z","totalItems":0} -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/9f252ada-b25c-418e-a8d4-1efa21cb0361/shares/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/9f252ada-b25c-418e-a8d4-1efa21cb0361/shares","type":"OrderedCollection","published":"2023-06-02T10:08:19Z","totalItems":0} -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects","type":"OrderedCollection","generator":"https://fedbox/","to":["https://www.w3.org/ns/activitystreams#Public"],"published":"2023-03-21T16:31:02Z","totalItems":10} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/a0f5c056-0671-4e87-846b-56577658c79f/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/a0f5c056-0671-4e87-846b-56577658c79f","type":"Note","mediaType":"text/html","content":"ana are mere
\n","attributedTo":"https://fedbox/actors/03bdf89a-9c59-481c-865c-be0316d810e7","context":"https://fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5","inReplyTo":"https://fedbox/objects/977897cf-ba11-414c-ac4d-de1cf3c6dfe5","replies":"https://fedbox/objects/a0f5c056-0671-4e87-846b-56577658c79f/replies","to":["https://fedbox/actors/03bdf89a-9c59-481c-865c-be0316d810e7","https://www.w3.org/ns/activitystreams#Public"],"cc":["https://fedbox/actors/03bdf89a-9c59-481c-865c-be0316d810e7","https://fedbox/actors/c4cdfe54-9919-4dd4-8a71-63beafe12b8c","https://fedbox/actors/c4cdfe54-9919-4dd4-8a71-63beafe12b8c/followers","https://fedbox/actors/03bdf89a-9c59-481c-865c-be0316d810e7/followers"],"bcc":["https://fedbox/"],"published":"2022-03-24T11:15:40Z","updated":"2022-03-24T11:15:40Z","likes":"https://fedbox/objects/a0f5c056-0671-4e87-846b-56577658c79f/likes","shares":"https://fedbox/objects/a0f5c056-0671-4e87-846b-56577658c79f/shares","source":{"mediaType":"text/markdown","content":"ana are mere"}} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/a0f5c056-0671-4e87-846b-56577658c79f/likes/04fb1d85-1ba8-4485-b5ba-3350c0a3b348: -------------------------------------------------------------------------------- 1 | ../../../activities/04fb1d85-1ba8-4485-b5ba-3350c0a3b348 -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/a0f5c056-0671-4e87-846b-56577658c79f/likes/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/a0f5c056-0671-4e87-846b-56577658c79f/likes","type":"OrderedCollection","generator":"https://fedbox/","published":"2023-03-21T16:31:02Z","totalItems":1} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/a0f5c056-0671-4e87-846b-56577658c79f/replies/0a99b75d-05a0-439e-9c09-d04a33b78de8: -------------------------------------------------------------------------------- 1 | ../../0a99b75d-05a0-439e-9c09-d04a33b78de8 -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/a0f5c056-0671-4e87-846b-56577658c79f/replies/2a6ffab9-39be-4ae6-ae1b-aa625fd0fb70: -------------------------------------------------------------------------------- 1 | ../../2a6ffab9-39be-4ae6-ae1b-aa625fd0fb70 -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/a0f5c056-0671-4e87-846b-56577658c79f/replies/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/a0f5c056-0671-4e87-846b-56577658c79f/replies","type":"OrderedCollection","generator":"https://fedbox/","published":"2023-03-21T16:31:02Z","totalItems":2} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/a527f654-aeec-4249-92fc-7cff563ffe2c/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/a527f654-aeec-4249-92fc-7cff563ffe2c","name":"#test","url":"https://brutalinks/t/test","to":["https://www.w3.org/ns/activitystreams#Public"]} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/e247130c-e18f-43dd-8655-5c6efbbc46c8/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/e247130c-e18f-43dd-8655-5c6efbbc46c8","name":"#sysop","summary":"Moderator tag for instance https://b804150cb032","attributedTo":"https://fedbox/actors/c4cdfe54-9919-4dd4-8a71-63beafe12b8c","replies":"https://fedbox/objects/e247130c-e18f-43dd-8655-5c6efbbc46c8/replies","url":"https://b804150cb032/t/sysop","to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://fedbox/actors/c4cdfe54-9919-4dd4-8a71-63beafe12b8c","https://fedbox/actors/c4cdfe54-9919-4dd4-8a71-63beafe12b8c/followers"],"bcc":["https://fedbox/"],"published":"2023-06-02T10:08:19Z","likes":"https://fedbox/objects/e247130c-e18f-43dd-8655-5c6efbbc46c8/likes","shares":"https://fedbox/objects/e247130c-e18f-43dd-8655-5c6efbbc46c8/shares"} -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/e247130c-e18f-43dd-8655-5c6efbbc46c8/likes/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/e247130c-e18f-43dd-8655-5c6efbbc46c8/likes","type":"OrderedCollection","published":"2023-06-02T10:08:19Z","totalItems":0} -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/e247130c-e18f-43dd-8655-5c6efbbc46c8/replies/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/e247130c-e18f-43dd-8655-5c6efbbc46c8/replies","type":"OrderedCollection","published":"2023-06-02T10:08:19Z","totalItems":0} -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/objects/e247130c-e18f-43dd-8655-5c6efbbc46c8/shares/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/objects/e247130c-e18f-43dd-8655-5c6efbbc46c8/shares","type":"OrderedCollection","published":"2023-06-02T10:08:19Z","totalItems":0} -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/outbox/__raw: -------------------------------------------------------------------------------- 1 | {"id":"https://fedbox/outbox","type":"OrderedCollection","generator":"https://fedbox/","published":"2023-03-21T16:31:02Z","totalItems":1} 2 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/fedbox/outbox/a4131607-7cd6-4b78-a893-b978d265ac7a: -------------------------------------------------------------------------------- 1 | ../activities/a4131607-7cd6-4b78-a893-b978d265ac7a -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/oauth/.gitignore: -------------------------------------------------------------------------------- 1 | clients/* 2 | !clients/fedbox/actors/c4cdfe54-9919-4dd4-8a71-63beafe12b8c 3 | access/* 4 | refresh/* 5 | -------------------------------------------------------------------------------- /tests/mocks/fedbox/fs/test/oauth/clients/fedbox/actors/c4cdfe54-9919-4dd4-8a71-63beafe12b8c/__raw: -------------------------------------------------------------------------------- 1 | {"Id":"c4cdfe54-9919-4dd4-8a71-63beafe12b8c","Secret":"asd","RedirectUri":"https://brutalinks/auth/fedbox/callback","Extra":"bnVsbA=="} 2 | -------------------------------------------------------------------------------- /tests/run-pods.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ENV=${ENV:-dev} 6 | TEST_PORT=${TEST_PORT:-4499} 7 | AUTH_IMAGE=${AUTH_IMAGE:-localhost/auth/app:dev} 8 | FEDBOX_IMAGE=${FEDBOX_IMAGE:-localhost/fedbox/app:dev} 9 | IMAGE=${IMAGE:-localhost/brutalinks/app:${ENV}} 10 | NETWORK=${NETWORK:-tests_network} 11 | 12 | if [ "${NETWORK}" != "host" ]; then 13 | if podman network exists ${NETWORK}; then 14 | podman network rm -f ${NETWORK} 15 | fi 16 | 17 | podman network create --subnet 10.6.6.0/24 --gateway 10.6.6.1 "${NETWORK}" 18 | fi 19 | 20 | podman run -d --replace \ 21 | --pull newer \ 22 | --name=tests_fedbox \ 23 | -v $(pwd)/mocks/fedbox/env:/.env \ 24 | -v $(pwd)/mocks/fedbox:/storage \ 25 | -e ENV=test \ 26 | -e STORAGE=fs \ 27 | -e LISTEN=:8443 \ 28 | -e HOSTNAME=fedbox \ 29 | --net "${NETWORK}" \ 30 | --network-alias fedbox-internal \ 31 | --ip 10.6.6.61 \ 32 | --expose 8443 \ 33 | ${FEDBOX_IMAGE} 34 | 35 | _fedbox_running=$(podman ps --filter name=tests_fedbox --format '{{ .Names }}') 36 | if [ -z "${_fedbox_running}" ]; then 37 | echo "Unable to run fedbox test pod: ${FEDBOX_IMAGE}" 38 | exit 1 39 | fi 40 | 41 | podman run -d --replace \ 42 | --pull newer \ 43 | --name=tests_auth \ 44 | -v $(pwd)/mocks/fedbox:/storage \ 45 | --net "${NETWORK}" \ 46 | --network-alias auth-internal \ 47 | --ip 10.6.6.62 \ 48 | --expose 8080 \ 49 | ${AUTH_IMAGE} \ 50 | --env test --listen :8080 --storage fs:///storage/%storage%/%env% 51 | 52 | _auth_running=$(podman ps --filter name=tests_auth --format '{{ .Names }}') 53 | if [ -z "${_auth_running}" ]; then 54 | echo "Unable to run test fedbox OAuth2 pod: ${AUTH_IMAGE}" 55 | exit 1 56 | fi 57 | 58 | podman run --replace -d \ 59 | -p ${TEST_PORT}:443 \ 60 | --name=tests_caddy \ 61 | -v $(pwd)/mocks/Caddyfile:/etc/caddy/Caddyfile \ 62 | -v caddy_data:/data \ 63 | --net "${NETWORK}" \ 64 | --network-alias fedbox \ 65 | --network-alias brutalinks \ 66 | --network-alias auth \ 67 | --ip 10.6.6.6 \ 68 | --expose 443 \ 69 | --expose 80 \ 70 | docker.io/library/caddy:2.7 71 | 72 | _caddy_running=$(podman ps --filter name=tests_caddy --format '{{ .Names }}') 73 | if [ -z "${_caddy_running}" ]; then 74 | echo "Unable to run test pod for Caddy" 75 | exit 1 76 | fi 77 | 78 | podman run -d --replace \ 79 | --pull newer \ 80 | --name=tests_brutalinks \ 81 | -v $(pwd)/mocks/brutalinks/env:/.env \ 82 | -v $(pwd)/mocks/brutalinks:/storage \ 83 | -e LISTEN_HOST=brutalinks \ 84 | --net "${NETWORK}" \ 85 | --network-alias brutalinks-internal \ 86 | --add-host fedbox:10.6.6.6 \ 87 | --add-host auth:10.6.6.6 \ 88 | --ip 10.6.6.66 \ 89 | --expose 8443 \ 90 | "${IMAGE}" 91 | sleep 1 92 | 93 | _brutalinks_running=$(podman ps --filter name=tests_brutalinks --format '{{ .Names }}') 94 | if [ -z "${_brutalinks_running}" ]; then 95 | echo "Unable to run Brutalinks test pod: ${IMAGE}" 96 | exit 1 97 | fi 98 | echo "Brutalinks pod running: ${IMAGE}" 99 | sleep 2 100 | 101 | -------------------------------------------------------------------------------- /votes.go: -------------------------------------------------------------------------------- 1 | package brutalinks 2 | 3 | import ( 4 | "time" 5 | 6 | vocab "github.com/go-ap/activitypub" 7 | "github.com/go-ap/errors" 8 | ) 9 | 10 | const ( 11 | ScoreMultiplier = 1 12 | ScoreMaxK = 10000.0 13 | ScoreMaxM = 10000000.0 14 | ScoreMaxB = 10000000000.0 15 | ) 16 | 17 | var ValidAppreciationTypes = vocab.ActivityVocabularyTypes{ 18 | vocab.LikeType, 19 | vocab.DislikeType, 20 | } 21 | 22 | type VoteCollection []Vote 23 | 24 | type VoteMetadata struct { 25 | IRI string `json:"-"` 26 | OriginalIRI string `json:"-"` 27 | } 28 | 29 | type Vote struct { 30 | SubmittedBy *Account `json:"-"` 31 | SubmittedAt time.Time `json:"-"` 32 | UpdatedAt time.Time `json:"-"` 33 | Weight int `json:"weight"` 34 | Item *Item `json:"on"` 35 | Flags FlagBits `json:"-"` 36 | Metadata *VoteMetadata `json:"-"` 37 | Pub *vocab.Activity `json:"-"` 38 | } 39 | 40 | func (v *Vote) ID() Hash { 41 | if v == nil { 42 | return AnonymousHash 43 | } 44 | return HashFromString(v.Metadata.IRI) 45 | } 46 | 47 | // HasMetadata 48 | func (v Vote) HasMetadata() bool { 49 | return v.Metadata != nil 50 | } 51 | 52 | // IsValid 53 | func (v *Vote) IsValid() bool { 54 | return v != nil && v.Item.IsValid() 55 | } 56 | 57 | // IsYay returns true if current vote is a Yay 58 | func (v Vote) IsYay() bool { 59 | if v.Pub == nil { 60 | return false 61 | } 62 | return v.Pub.GetType() == vocab.LikeType 63 | } 64 | 65 | // IsNay returns true if current vote is a Nay 66 | func (v Vote) IsNay() bool { 67 | if v.Pub == nil { 68 | return false 69 | } 70 | return v.Pub.GetType() == vocab.DislikeType 71 | } 72 | 73 | // AP returns the underlying actvitypub item 74 | func (v *Vote) AP() vocab.Item { 75 | return v.Pub 76 | } 77 | 78 | // Type 79 | func (v *Vote) Type() RenderType { 80 | return AppreciationType 81 | } 82 | 83 | // Date 84 | func (v Vote) Date() time.Time { 85 | return v.SubmittedAt 86 | } 87 | 88 | func (v *Vote) Children() *RenderableList { 89 | return nil 90 | } 91 | 92 | func (v VoteCollection) Contains(vot Vote) bool { 93 | for _, vv := range v { 94 | if !vv.HasMetadata() || !vot.HasMetadata() { 95 | continue 96 | } 97 | if vv.Metadata.IRI == vot.Metadata.IRI { 98 | return true 99 | } 100 | } 101 | return false 102 | } 103 | 104 | type ScoreType int 105 | 106 | const ( 107 | ScoreItem = ScoreType(iota) 108 | ScoreAccount 109 | ) 110 | 111 | func (v VoteCollection) First() (*Vote, error) { 112 | for _, vv := range v { 113 | return &vv, nil 114 | } 115 | return nil, errors.Errorf("empty %T", v) 116 | } 117 | 118 | // Score 119 | func (v VoteCollection) Score() int { 120 | score := 0 121 | for _, vot := range v { 122 | score += vot.Weight 123 | } 124 | return score 125 | } 126 | -------------------------------------------------------------------------------- /webfinger.go: -------------------------------------------------------------------------------- 1 | package brutalinks 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/fs" 8 | "net/http" 9 | "path" 10 | "regexp" 11 | 12 | "git.sr.ht/~mariusor/brutalinks/internal/assets" 13 | log "git.sr.ht/~mariusor/lw" 14 | "github.com/go-ap/filters" 15 | "github.com/writeas/go-nodeinfo" 16 | ) 17 | 18 | type link struct { 19 | Rel string `json:"rel,omitempty"` 20 | Type string `json:"type,omitempty"` 21 | Href string `json:"href,omitempty"` 22 | Template string `json:"template,omitempty"` 23 | } 24 | 25 | type node struct { 26 | Subject string `json:"subject"` 27 | Aliases []string `json:"aliases"` 28 | Links []link `json:"links"` 29 | } 30 | 31 | type NodeInfoResolver struct { 32 | users int 33 | comments int 34 | posts int 35 | } 36 | 37 | var ( 38 | actorsFilter = filters.HasType(ValidActorTypes...) 39 | postsFilter = filters.All( 40 | filters.HasType(ValidContentTypes...), 41 | filters.Not(filters.NilInReplyTo), 42 | ) 43 | allFilter = filters.HasType(ValidContentTypes...) 44 | ) 45 | 46 | func NodeInfoResolverNew(r *repository) NodeInfoResolver { 47 | n := NodeInfoResolver{} 48 | if r == nil { 49 | return n 50 | } 51 | 52 | loadFn := func(f filters.Check, fn func(int) error) error { 53 | res, err := r.b.Search(f) 54 | if err != nil { 55 | return err 56 | } 57 | return fn(len(res)) 58 | } 59 | 60 | _ = loadFn(actorsFilter, func(cnt int) error { 61 | n.users = cnt 62 | return nil 63 | }) 64 | _ = loadFn(postsFilter, func(cnt int) error { 65 | n.posts = cnt 66 | return nil 67 | }) 68 | _ = loadFn(allFilter, func(cnt int) error { 69 | n.comments = cnt - n.posts 70 | return nil 71 | }) 72 | return n 73 | } 74 | 75 | func (n NodeInfoResolver) IsOpenRegistration() (bool, error) { 76 | return Instance.Conf.UserCreatingEnabled, nil 77 | } 78 | 79 | func (n NodeInfoResolver) Usage() (nodeinfo.Usage, error) { 80 | u := nodeinfo.Usage{ 81 | Users: nodeinfo.UsageUsers{ 82 | Total: n.users, 83 | }, 84 | LocalComments: n.comments, 85 | LocalPosts: n.posts, 86 | } 87 | return u, nil 88 | } 89 | 90 | const ( 91 | softwareName = "brutalinks" 92 | sourceURL = "https://git.sr.ht/~mariusor/brutalinks" 93 | author = "@mariusor@metalhead.club" 94 | ) 95 | 96 | func NodeInfoConfig() nodeinfo.Config { 97 | ni := Instance.NodeInfo() 98 | return nodeinfo.Config{ 99 | BaseURL: Instance.BaseURL.String(), 100 | InfoURL: "/nodeinfo", 101 | 102 | Metadata: nodeinfo.Metadata{ 103 | NodeName: string(regexp.MustCompile(`<[\/\w]+>`).ReplaceAll([]byte(ni.Title), []byte{})), 104 | NodeDescription: ni.Summary, 105 | Private: !Instance.Conf.UserCreatingEnabled, 106 | Software: nodeinfo.SoftwareMeta{ 107 | GitHub: sourceURL, 108 | HomePage: Instance.BaseURL.String(), 109 | Follow: Instance.Conf.AdminContact, 110 | }, 111 | }, 112 | Protocols: []nodeinfo.NodeProtocol{ 113 | nodeinfo.ProtocolActivityPub, 114 | }, 115 | Services: nodeinfo.Services{ 116 | Inbound: []nodeinfo.NodeService{}, 117 | Outbound: []nodeinfo.NodeService{nodeinfo.ServiceAtom, nodeinfo.ServiceRSS}, 118 | }, 119 | Software: nodeinfo.SoftwareInfo{ 120 | Name: path.Base(softwareName), 121 | Version: ni.Version, 122 | }, 123 | } 124 | } 125 | 126 | // HandleHostMeta serves /.well-known/host-meta 127 | func (h handler) HandleHostMeta(w http.ResponseWriter, r *http.Request) { 128 | hm := node{ 129 | Subject: "", 130 | Aliases: nil, 131 | Links: []link{ 132 | { 133 | Rel: "lrdd", 134 | Type: "application/xrd+json", 135 | Template: fmt.Sprintf("%s/.well-known/node?resource={uri}", h.conf.BaseURL), 136 | }, 137 | }, 138 | } 139 | dat, _ := json.Marshal(hm) 140 | 141 | w.Header().Set("Content-Type", "application/jrd+json") 142 | w.WriteHeader(http.StatusOK) 143 | _, _ = w.Write(dat) 144 | } 145 | 146 | const selfName = "self" 147 | 148 | func (a Application) NodeInfo() WebInfo { 149 | // Name formats the name of the current Application 150 | inf := WebInfo{ 151 | Title: a.Conf.Name, 152 | Summary: "Link aggregator inspired by reddit and hacker news using ActivityPub federation.", 153 | Email: a.Conf.AdminContact, 154 | URI: a.BaseURL.String(), 155 | Version: a.Version, 156 | } 157 | 158 | if desc, err := fs.ReadFile(assets.AssetFS, "README.md"); err == nil { 159 | inf.Description = string(bytes.Trim(desc, "\x00")) 160 | } else { 161 | a.Logger.WithContext(log.Ctx{"err": err}).Errorf("unable to load README.md file from fs: %s", assets.AssetFS) 162 | } 163 | return inf 164 | } 165 | --------------------------------------------------------------------------------