diff --git a/.gitignore b/.gitignore index e4f4c16..dadea21 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ tests/db # vendor dir vendor/ +trash/ diff --git a/Gopkg.lock b/Gopkg.lock index 10ef811..f7a5c56 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -1,9 +1,121 @@ # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. +[[projects]] + digest = "1:3ee1d175a75b911a659fbd860060874c4f503e793c5870d13e5a0ede529a63cf" + name = "github.com/gin-contrib/sse" + packages = ["."] + pruneopts = "UT" + revision = "54d8467d122d380a14768b6b4e5cd7ca4755938f" + version = "v0.1.0" + +[[projects]] + digest = "1:d8bd2a337f6ff2188e08f72c614f2f3f0fd48e6a7b37a071b197e427d77d3a47" + name = "github.com/gin-gonic/gin" + packages = [ + ".", + "binding", + "internal/json", + "render", + ] + pruneopts = "UT" + revision = "b75d67cd51eb53c3c3a2fc406524c940021ffbda" + version = "v1.4.0" + +[[projects]] + digest = "1:573ca21d3669500ff845bdebee890eb7fc7f0f50c59f2132f2a0c6b03d85086a" + name = "github.com/golang/protobuf" + packages = ["proto"] + pruneopts = "UT" + revision = "6c65a5562fc06764971b7c5d05c76c75e84bdbf7" + version = "v1.3.2" + +[[projects]] + digest = "1:709cd2a2c29cc9b89732f6c24846bbb9d6270f28ef5ef2128cc73bd0d6d7bff9" + name = "github.com/json-iterator/go" + packages = ["."] + pruneopts = "UT" + revision = "27518f6661eba504be5a7a9a9f6d9460d892ade3" + version = "v1.1.7" + +[[projects]] + digest = "1:31e761d97c76151dde79e9d28964a812c46efc5baee4085b86f68f0c654450de" + name = "github.com/konsorten/go-windows-terminal-sequences" + packages = ["."] + pruneopts = "UT" + revision = "f55edac94c9bbba5d6182a4be46d86a2c9b5b50e" + version = "v1.0.2" + +[[projects]] + digest = "1:36325ebb862e0382f2f14feef409ba9351271b89ada286ae56836c603d43b59c" + name = "github.com/mattn/go-isatty" + packages = ["."] + pruneopts = "UT" + revision = "e1f7b56ace729e4a73a29a6b4fac6cd5fcda7ab3" + version = "v0.0.9" + +[[projects]] + digest = "1:33422d238f147d247752996a26574ac48dcf472976eda7f5134015f06bf16563" + name = "github.com/modern-go/concurrent" + packages = ["."] + pruneopts = "UT" + revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" + version = "1.0.3" + +[[projects]] + digest = "1:e32bdbdb7c377a07a9a46378290059822efdce5c8d96fe71940d87cb4f918855" + name = "github.com/modern-go/reflect2" + packages = ["."] + pruneopts = "UT" + revision = "4b7aa43c6742a2c18fdef89dd197aaae7dac7ccd" + version = "1.0.1" + +[[projects]] + digest = "1:04457f9f6f3ffc5fea48e71d62f2ca256637dee0a04d710288e27e05c8b41976" + name = "github.com/sirupsen/logrus" + packages = ["."] + pruneopts = "UT" + revision = "839c75faf7f98a33d445d181f3018b5c3409a45e" + version = "v1.4.2" + +[[projects]] + digest = "1:1d4ed6179f215ac661d5c447022b4d10074fbd8829b55e2d405d2a65496a278b" + name = "github.com/ugorji/go" + packages = ["codec"] + pruneopts = "UT" + revision = "23ab95ef5dc3b70286760af84ce2327a2b64ed62" + version = "v1.1.7" + +[[projects]] + branch = "master" + digest = "1:767025ef5997eeba9a3b7fba288b8b9e71b44f0e3a2ec6dcd01f144d3dc8f173" + name = "golang.org/x/sys" + packages = ["unix"] + pruneopts = "UT" + revision = "749cb33beabd9aa6d3178e3de05bcc914f70b2bf" + +[[projects]] + digest = "1:cbc72c4c4886a918d6ab4b95e347ffe259846260f99ebdd8a198c2331cf2b2e9" + name = "gopkg.in/go-playground/validator.v8" + packages = ["."] + pruneopts = "UT" + revision = "5f1438d3fca68893a817e4a66806cea46a9e4ebf" + version = "v8.18.2" + +[[projects]] + digest = "1:4d2e5a73dc1500038e504a8d78b986630e3626dc027bc030ba5c75da257cdb96" + name = "gopkg.in/yaml.v2" + packages = ["."] + pruneopts = "UT" + revision = "51d6538a90f86fe93ac480b35f37b2be17fef232" + version = "v2.2.2" + [solve-meta] analyzer-name = "dep" analyzer-version = 1 - input-imports = [] + input-imports = [ + "github.com/gin-gonic/gin", + "github.com/sirupsen/logrus", + ] solver-name = "gps-cdcl" solver-version = 1 diff --git a/README.md b/README.md index 70ddda0..6e14ee7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,151 @@ -# subhub +# Subhub + +An _experimental_ (Read: not-usable or in anyway done) distributed/federated podcasting platform based on [ActivityPub.](https://raw.githubusercontent.com/w3c/activitypub/gh-pages/activitypub-tutorial.txt) + +## Usage + +Getting started: + +Ensure that you're using go11 with go-modules turned on. + +```sh +export GO111MODULE=on # Put this in your .zshrc or .bash_profile or whatnot +``` + +Clone/Download the project with: + +```sh +go get git.macaw.me/inhosin/subhub +``` + +Building a binary with make (or [mmake](https://github.com/tj/mmake) if you're fancy): + +```sh +make +``` + +Building and then running that binary: + +```sh +make run +``` + +Running tests: + +```sh +make test +``` + +Setting up your database (this works best if you have postgres already [running locally](https://postgresapp.com/)): + +```sh +make database +``` + +Creating a new migration in `db/migrations`: + +```sh +make migration NAME=some_name_here +``` + +## Learning about ActivityPub + +![explaination](https://i.imgur.com/ShgecWe.png) + +### Basic Description + +ActivityPub gives every user (or `actor` in it's vocab) on a server an "inbox" and an "outbox". But these are really just endpoints: + +``` +https://myactpub.site/activity/user/flaque/inbox +https://myactpub.site/activity/user/flaque/outbox +``` + +ActivityPub asks that you accept `GET` and `POST` requests to these endpoints where a `POST` tells a the server to put that in a user's queue or feed and `GET` lets the user retrieve info from the feed. + +You send messages called `ActivityStreams` that are really just a special spec of JSON: + +```json +{"@context": "https://www.w3.org/ns/activitystreams", + "type": "Create", + "id": "https://social.example/alyssa/posts/a29a6843-9feb-4c74-a7f7-081b9c9201d3", + "to": ["https://chatty.example/ben/"], + "author": "https://social.example/alyssa/", + "object": {"type": "Note", + "id": "https://social.example/alyssa/posts/49e2d03d-b53a-4c4c-a95c-94a6abf45a19", + "attributedTo": "https://social.example/alyssa/", + "to": ["https://chatty.example/ben/"], + "content": "Say, did you finish reading that book I lent you?"} +``` + +#### Objects, Actors, and Activities + +(**Note:** Pubcast uses a slightly different internal naming than ActivityPub. To have more understandable code in the context of podcasts, ActivityPub's `Organization` actor type is a `Show` inside Pubcast. Additionally, the `Object` type is a Pubcast `Episode`.) + +ActivityPub is based on [a formalized vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/) of data types, actions and folks doing the actions. + +An `Object` is a generic data type written in JSON: + +```json +{ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Object", + "id": "http://www.test.example/object/1", + "name": "A Simple, non-specific object" +} +``` + +Objects have [a set collection of formalized properties](https://www.w3.org/TR/activitystreams-vocabulary/#properties) such as `id`, `name`, `url`, etc but you technically can create your own. Objects serve as a base type for other Activity Steam's core set of types. + +For example, there are a set of [actor types](https://www.w3.org/TR/activitystreams-vocabulary/#actor-types) that themselves are `Objects`. + +```json +/* A "Person" actor type */ +{ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Person", + "name": "Sally Smith" +} +``` + +[Activities](https://www.w3.org/TR/activitystreams-vocabulary/#h-activity-types) are also subtypes of `Object`, and are used to describe relationships between objects. Some examples of activities include: + +- Accept +- Create +- Move +- Question +- Undo +- Follow +- View + +An `Activity` json might look something like this: + +```json +{ + "@context": "https://www.w3.org/ns/activitystreams", + "summary": "Sally created a note", + "type": "Create", + "actor": { + "type": "Person", + "name": "Sally" + }, + "object": { + "type": "Note", + "name": "A Simple Note", + "content": "This is a simple note" + } +} +``` + +### Links + +- [ActivityPub tutorial](https://raw.githubusercontent.com/w3c/activitypub/gh-pages/activitypub-tutorial.txt) +- [ActivityPub.rocks explaination](https://activitypub.rocks/) +- [W3 ActivityPub Spec](https://www.w3.org/TR/activitypub/) +- [W3 ActivityPub Vocabulary Spec](https://www.w3.org/TR/activitystreams-vocabulary/) +- [Other Golang implementations of this spec](https://github.com/go-fed/activity#who-is-using-this-library-currently) + +## Design + +### Hub Object -Subscriber hubs \ No newline at end of file diff --git a/TZ.md b/TZ.md new file mode 100644 index 0000000..cde022d --- /dev/null +++ b/TZ.md @@ -0,0 +1,91 @@ + +## Описание компонентов движка + +### Бэкенд + +Работает в виде демона, реализующего 3 вида API: + +1) Activitypub для межсерверного взаимодействия +2) Публичный API для доступа к тредам (будет использоваться фронтендом для показа тредов обсуждения на публичной странице сообщества) +3) API администрирования (будет использоваться административной частью фронтенда, в которую будут логиниться пользователи-модераторы групп и администратор сервера) + +Основные сущности, которые таким образом реализует бэкенд: + +Акторы - согласно понятию актора из Устава, механизм публикации и подписки в рамках Fediverse. Имеют адрес вида who@where.tld. В нашем случае, акторы имеют тип "Group", и в терминолгогии проекта называются "хабами" +Хаб - специальный актор, осуществляющий групповое общение пользователей Fediverse. +Аккаунт модератора - рядовой аккаунт в системе SubHub, который дает право создавать группы и управлять ими. +Аккаунт администратора - административный аккаунт в системе SubHub, который дает право производить административные действия над сервером и аккаунтами модераторов. + +Общий алгоритм работы сервера: + +1) Сервер получает оповещение об упоминании актора одного из акторов на нем; +2) Если упоминаемый актор действительно существует, идет прверка свойств хаба (см. раздел о возможных видах хабов). Здесь рассмотрим случай, где члены хаба = подписчики; +3) Проверяем сообщение, в котором упоминается актор. Если сообщение первое в треде (у него не имеется, in-reply-to), записываем его в БД как новый тред, и объявляем активность Announce относительно этого сообщения. Таким образом, все подписчики хаба получат оригинальное сообщение, на которое можно будет отвечать и реагировать; +4) Если сообщение с упоминанием не первое в треде, проверяем (рекурсивно), не принадлежит ли оно к какому-нибудь треду, уже присутствующему на сервере. Если принадлежит, присоединяем его и все предыдущие сообщения к треду в базе данных; +5) Если сообщение с упоминанием не первое в треде, и не принадлежит к ни одному существующему на сервере треду, то проверяем, разрешено ли "прикалывание" (?) тредов пользователям. Если да, то забираем это сообщение, и рекурсивно все предыдущие вплоть до первого. Первое сообщение обозначаем в базе как начало треда, с информацией о том,от кого было "прикалывающее сообщение"; +6) За существующими тредами продолжаем наблюдать на предмет появления новых сообщений, и записываем в базу если таковые находятся; +7) Клиентскому фронтенду по запросу на API отдаем информацию о группе, список ее тредов, и, собственно, сами треды для просмотра в браузере. +8) Административному фронтенду позволяем авторизовываться как модератор или администратор, и предоставляем в зависимости от этого управление сервером целиком или управление только своими группами. +9) Производим регулярную проверку хотя бы первых сообщений в тредах, на предмет новых реакций - обновляем счетчики реакций соответственно. + +Таким образом, общий алгоритм использования следующий: + + - Администратор устанавливает сервер. Допустим, subhub.com + - Модератор регистрируется на сервере и создает группу (или несколько). Выбирает группе аватар, фон, возможно CSS-тему, и т.д. Допустим, группа rf@subhub.com + - Пользователи присоединяются к группе, путем подписки на нее. + - Пользователь alisa@mastodon.social, подписчица rf@subhub.com, создает сообщение, первое в треде, с упомининием rf@subhub.com + - Сервер subhub.com замечает это, записывает сообщение-топикстартер в базу, и дает Announce всем подписчикам rf@subhub.com + - Все последующие сообщения в треде сервер будет записывать в базу, где-то получая их по упоминаниям, а где-то где упоминание потеряли, захватывать их самостоятельно (если это позволено модератором и администратором) + "Прикалывание" тредов: + - Пользователь pazan@mastodon.ml наткнулся на интересное обсуждение в Fediverse, и решил обнародовать этот тред для членов группы rf@subhub.com, в которой разрешено прикалывание; + - Для этого он создает сообщение-ответ в треде, в которм упоминает rf@subhub.com. Shubub.com получает активность упоминания; + - Subhub.com ищет по цепочке in-reply-to первое сообщение в треде, записывает его в базу и издает Announce в отношении него, с пометкой о том, что данный тред был приколот pazan@mastodon.ml, а также берет весь тред в базу целиком, со всеми существующими ветвями. + - Все последующие сообщения в треде сервер будет записывать в базу, где-то получая их по упоминаниям, а где-то где упоминание потеряли, захватывать их самостоятельно (если это позволено модератором и администратором) + +Виды хабов: + + - Доска объявлений: Тред может создать кто угодно, членство не обязательно; + - Группа: Тред может создать только подписчик; + - Клуб: Тред может создать только подписчик, при этом подписчиков необходимо подтверждать модератору; + - Канал: Тред может создать лишь строго ограниченный список лиц, заданный модератором. + +Прикалывание сообщений и принудительный захват тредов - опции, определяемые модератором. Администратор может запрещать эти опции. + +### Фронтенд + +Фронтенд делится на две части: публичный и административный + +#### Публичный фронтенд + +Шлет запросы на публичный API с целью получения данных о хабах и тредах. Таким образом, осуществляет просмотр архива обсуждений на хабах. Имеет интерфейс, похожий на Reddit, позволяет осуществлять сортировку по дате добавления, дате последнего сообщения, по популярности, и т.д. Имеет два основных вида: + + - Страница хаба. На ней находится название хаба, аватар и фон хаба, адрес хаба, количество подписчиков, адрес модератора для связи (задается на странице модератора, принимает на вход адрес актора - в другом соцдвижке, не-Subhub), описание хаба (задается модератором хаба), его тип. Помимо всей информации о хабе, имеет список тредов, который можно сортировать, и осуществлять поиск. + - Страница треда. На ней находится главным образом та же самая информация, что на странице хаба, однако вместо списка тредов на странице находится обсуждение, в виде reddit-овского дерева. Каждое сообщение в треде (помимо обычной информации вроде тела сообщения и количества реакций) имеет ссылку на оригинал, на сервере человека, его написавшего. + +Для его просмотра не требуется авторизация. + +#### Административный фронтенд + +Авторизует пользователя, и шлет запросы на административный API, который позволяет: + +Модераторам - + - Определить личный параметры (вроде адреса своего актора для связи) + - Создавать хабы + - Редактировать параметры хаба (аватар, фон, описание, тип, функции прикалывания и захвата тредов) + - Редактировать список подписчиков (иногда нужно отписать или заблокировать какого-нибудь хулигана, или даже заблокировать домен) + - Редактировать список тредов хаба и сами треды (иногда нужно удалить тред или сообщение) + - В случае, если хаб - канал, определять список доступа к созданию тредов + - Закрывать/удалять хабы + +Администраторам - + - Все, что можно модераторам + - Определять параметры сервера (его описание, правила, оформление, технические опции, например, возможность использовать захват тредов и прикалывание) + - Просматирвать и редактировать список модераторов на сервере + - Блокировка доменов и пользователей по всему серверу + +В итоге получаем работающий форум на отдельном движке в Fediverse: + + - Модераторы создают хабы; + - Пользователи других серверовы пишут в хабы + - За обсуждением можно наблюдать, даже не находясь в Fediverse. +Вот значимая часть, с описанием архитектуры проги. Жуткий сумбур, и это явно не ТЗ, но пока то, что есть. \ No newline at end of file diff --git a/activitypub/README.md b/activitypub/README.md new file mode 100644 index 0000000..b5178af --- /dev/null +++ b/activitypub/README.md @@ -0,0 +1,3 @@ +# ActivityPub + +This package contains functions for handling the ActivityPub spec. \ No newline at end of file diff --git a/activitypub/activity.go b/activitypub/activity.go new file mode 100644 index 0000000..8959e14 --- /dev/null +++ b/activitypub/activity.go @@ -0,0 +1,145 @@ +package activitypub + +import "time" + +const ( + Namespace = "https://www.w3.org/ns/activitystreams" + toPublic = "https://www.w3.org/ns/activitystreams#Public" +) + +var Extensions = map[string]string{ + "sc": "http://schema.org#", + "commentsEnabled": "sc:Boolean", +} + +// Activity describes actions that have either already occurred, are in the +// process of occurring, or may occur in the future. +type Activity struct { + BaseObject + Actor string `json:"actor"` + Published time.Time `json:"published,omitempty"` + To []string `json:"to,omitempty"` + CC []string `json:"cc,omitempty"` + Object *Object `json:"object"` +} + +type FollowActivity struct { + BaseObject + Actor string `json:"actor"` + Published time.Time `json:"published,omitempty"` + To []string `json:"to,omitempty"` + CC []string `json:"cc,omitempty"` + Object string `json:"object"` +} + +// NewCreateActivity builds a basic Create activity that includes the given +// Object and the Object's AttributedTo property as the Actor. +func NewCreateActivity(o *Object) *Activity { + a := Activity{ + BaseObject: BaseObject{ + Context: []interface{}{ + Namespace, + Extensions, + }, + ID: o.ID, + Type: "Create", + }, + Actor: o.AttributedTo, + Object: o, + Published: o.Published, + } + return &a +} + +// NewUpdateActivity builds a basic Update activity that includes the given +// Object and the Object's AttributedTo property as the Actor. +func NewUpdateActivity(o *Object) *Activity { + a := Activity{ + BaseObject: BaseObject{ + Context: []interface{}{ + Namespace, + Extensions, + }, + ID: o.ID, + Type: "Update", + }, + Actor: o.AttributedTo, + Object: o, + Published: o.Published, + } + return &a +} + +// NewDeleteActivity builds a basic Delete activity that includes the given +// Object and the Object's AttributedTo property as the Actor. +func NewDeleteActivity(o *Object) *Activity { + a := Activity{ + BaseObject: BaseObject{ + Context: []interface{}{ + Namespace, + }, + ID: o.ID, + Type: "Delete", + }, + Actor: o.AttributedTo, + Object: o, + } + return &a +} + +// NewFollowActivity builds a basic Follow activity. +func NewFollowActivity(actorIRI, followeeIRI string) *FollowActivity { + a := FollowActivity{ + BaseObject: BaseObject{ + Context: []interface{}{ + Namespace, + }, + Type: "Follow", + }, + Actor: actorIRI, + Object: followeeIRI, + } + return &a +} + +// NewNoteObject creates a basic Note object that includes the public +// namespace in IRIs it's addressed to. +func NewNoteObject() *Object { + o := Object{ + BaseObject: BaseObject{ + Type: "Note", + }, + To: []string{ + toPublic, + }, + } + return &o +} + +// NewArticleObject creates a basic Article object that includes the public +// namespace in IRIs it's addressed to. +func NewArticleObject() *Object { + o := Object{ + BaseObject: BaseObject{ + Type: "Article", + }, + To: []string{ + toPublic, + }, + } + return &o +} + +// NewAnounceObject creates a basic Anounce object that includes the public +// namespace in IRIs it's addressed to. +func NewAnounceObject() *Object { + o := Object{ + BaseObject: BaseObject{ + Type: "Anounce", + }, + To: []string{ + toPublic, + }, + } + return &o +} diff --git a/activitypub/person.go b/activitypub/person.go new file mode 100644 index 0000000..653cd32 --- /dev/null +++ b/activitypub/person.go @@ -0,0 +1,57 @@ +package activitypub + +// An Person actor +// Ref: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person +type Person struct { + BaseObject + Inbox string `json:"inbox"` + Outbox string `json:"outbox"` + PreferredUsername string `json:"preferredUsername"` + URL string `json:"url"` + Name string `json:"name"` + Icon Image `json:"icon"` + Following string `json:"following"` + Followers string `json:"followers"` + Summary string `json:"summary"` + PublicKey PublicKey `json:"publicKey"` + Endpoints Endpoints `json:"endpoints"` +} + +// NewPerson creates an ActivityPub Person +func NewPerson(id string) *Person { + p := Person{ + BaseObject: BaseObject{ + Type: "Person", + Context: []interface{}{ + Namespace, + }, + ID: id, + }, + Following: id + "/following", + Followers: id + "/followers", + Inbox: id + "/inbox", + Outbox: id + "/outbox", + } + + return &p +} + +// AddPubKey add public key to Person +func (p *Person) AddPubKey(k []byte) { + p.Context = append(p.Context, "https://w3id.org/security/v1") + p.PublicKey = PublicKey{ + ID: p.ID + "#main-key", + Owner: p.ID, + PublicKeyPEM: string(k), + } +} + +// SetPrivateKey set private key to Person +func (p *Person) SetPrivateKey(k []byte) { + p.PublicKey.privateKey = k +} + +// GetPrivateKey get private key +func (p *Person) GetPrivateKey() []byte { + return p.PublicKey.privateKey +} diff --git a/activitypub/vocab.go b/activitypub/vocab.go new file mode 100644 index 0000000..d8d3cda --- /dev/null +++ b/activitypub/vocab.go @@ -0,0 +1,107 @@ +package activitypub + +import ( + "fmt" + "net/url" + "time" +) + +const context = "https://ww.w3.org/ns/activitystreams" + +type ( + BaseObject struct { + Context []interface{} `json:"@context,omitempty"` + Type string `json:"type"` + ID string `json:"id"` + } + + PublicKey struct { + ID string `json:"id"` + Owner string `json:"owner"` + PublicKeyPEM string `json:"publicKeyPem"` + privateKey []byte + } + + Endpoints struct { + SharedInbox string `json:"sharedInbox,omitempty"` + } + + Image struct { + Type string `json:"type"` + MediaType string `json:"mediaType"` + URL string `json:"url"` + } + + // Object is the primary base type for the Activity Streams vocabulary. + Object struct { + BaseObject + Published time.Time `json:"published"` + Summary *string `json:"summary,omitempty"` + InReplyTo *string `json:"inReplyTo"` + URL string `json:"url"` + AttributedTo string `json:"attributedTo"` + To []string `json:"to"` + CC []string `json:"cc,omitempty"` + Name string `json:"name,omitempty"` + Content string `json:"content"` + ContentMap map[string]string `json:"contentMap,omitempty"` + //Tag []Tag `json:"tag"` + + // Extensions + CommentsEnabled bool `json:"commentsEnabled"` + } + + Group struct { + Context string `json:"@context"` + ID *url.URL `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + } +) + +type OrderedCollection struct { + BaseObject + TotalItems int `json:"totalItems"` + First string `json:"first"` + Last string `json:"last,omitempty"` +} + +func NewOrderedCollection(accountRoot, collType string, items int) *OrderedCollection { + oc := OrderedCollection{ + BaseObject: BaseObject{ + Context: []interface{}{ + Namespace, + }, + ID: accountRoot + "/" + collType, + Type: "OrderedCollection", + }, + First: accountRoot + "/" + collType + "?page=1", + TotalItems: items, + } + return &oc +} + +type OrderedCollectionPage struct { + BaseObject + TotalItems int `json:"totalItems"` + PartOf string `json:"partOf"` + Next string `json:"next,omitempty"` + Prev string `json:"prev,omitempty"` + OrderedItems []interface{} `json:"orderedItems,omitempty"` +} + +func NewOrderedCollectionPage(accountRoot, collType string, items, page int) *OrderedCollectionPage { + ocp := OrderedCollectionPage{ + BaseObject: BaseObject{ + Context: []interface{}{ + Namespace, + }, + ID: fmt.Sprintf("%s/%s?page=%d", accountRoot, collType, page), + Type: "OrderedCollectionPage", + }, + TotalItems: items, + PartOf: accountRoot + "/" + collType, + Next: fmt.Sprintf("%s/%s?page=%d", accountRoot, collType, page+1), + } + return &ocp +} diff --git a/app/account.go b/app/account.go new file mode 100644 index 0000000..2cf110f --- /dev/null +++ b/app/account.go @@ -0,0 +1,7 @@ +package app + +type SSHKey struct { + ID string `json:"id"` + Private []byte `json:"prv,omitempty"` + Public []byte `json:"pub,omitempty"` +} diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..012c77c --- /dev/null +++ b/app/app.go @@ -0,0 +1,46 @@ +package app + +import ( + "database/sql" + "flag" + "log" + "os" +) + +const ( + serverName = "Sub Hub" + softwareVer = "0.0.1" + configFile = "config.json" +) + +type app struct { + db *sql.DB + cfg *config +} + +type config struct { + Host string `json:"host"` + Port int `json:"port"` + MySQLConnStr string `json:"mysql_conn_str"` + Name string `json:"instance_name"` +} + +func Serve() { + app := &app{ + cfg: &config{}, + } + + var newUser, newPass string + flag.IntVar(&app.cfg.Port, "p", 8090, "Port to start server on") + flag.StringVar(&app.cfg.Host, "h", "", "Serer base URL") + + // options for creating a new user + flag.StringVar(&newUser, "user", "", "New user's username. Should be paired with --pass") + flag.StringVar(&newPass, "pass", "", "Password for new user. Should be paired with --user") + flag.Parse() + + if app.cfg.Host == "" || os.Getenv("SH_MYSQL_CONNECTION") == "" { + log.Printf("Reding %s", configFile) + //f, err := ioutil.ReadFile(configFile) + } +} diff --git a/config/constants.go b/config/constants.go new file mode 100644 index 0000000..53a8fce --- /dev/null +++ b/config/constants.go @@ -0,0 +1,6 @@ +package config + +const ( + ServerHostName = "subhub.hostname" + ServerPort = "subhub.port" +) diff --git a/data/data.go b/data/data.go new file mode 100644 index 0000000..3016786 --- /dev/null +++ b/data/data.go @@ -0,0 +1,32 @@ +package data + +import ( + "database/sql" + "fmt" +) + +var ( + pool *sql.DB +) + +func GetDB() *sql.DB { + if pool == nil { + // TODO write text + panic("") + } + return pool +} + +func NewDB() (*sql.DB, error) { + const ( + host = "localhost" + user = "admin" + pass = "Bambukalo2201" + dbname = "subhub" + ) + + dbConnString := fmt.Sprintf("%s:%s@%s/%s?charset=utf8mb4&parseTime=true", + user, pass, host, dbname) + + return sql.Open("mysql", dbConnString) +} diff --git a/data/models/group.go b/data/models/group.go new file mode 100644 index 0000000..957c375 --- /dev/null +++ b/data/models/group.go @@ -0,0 +1,52 @@ +package models + +import ( + "database/sql" + "time" + + slugify "github.com/gosimple/slug" +) + +// Group is a collection of Hubs (equivalent to ActivityPub Organizations) +// Refers to the https://www.w3.org/TR/activitystreams-vocabulary/#dfn-group +// Also refers to the Groups table in the database +type Group struct { + Slug string `json:"slug"` + Name string `json:"name"` + Note string `json:"note"` + Type string + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GetGroup returns a single Group object or nil +func GetGroup(db *sql.DB, slug string) (*Group, error) { + row := db.QueryRow(` + select slug, name, note, created_at, updated_at + from groups where slug = $1 + `, slug) + + var group Group + err := row.Scan(&group.Slug, &group.Name, &group.Note, &group.CreatedAt, &group.UpdatedAt) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &group, nil +} + +// PutGroup put new group into db and return slur or error +func PutGroup(db *sql.DB, name string, note string) (string, error) { + // TODO: make slugify "github.com/gosimple/slug" + slug := slugify.MakeLang(name, "en") + + query := ` + insert into groups(slug, name, note) + values ($1, $2, $3) + ` + _, err := db.Exec(query, slug, name, note) + return slug, err +} diff --git a/handlers/README.md b/handlers/README.md new file mode 100644 index 0000000..48d5973 --- /dev/null +++ b/handlers/README.md @@ -0,0 +1,6 @@ +# Handlers + +This folder contains [Mux](https://github.com/gorilla/mux) route handlers. If you've worked in webservers in other languages, it's similar to the concept of controllers. + +It's common to see a handler take some information and spit it to something in `lib`. This is because handlers convert web-server knowledge to buisness-logic rather than do everything at once. + diff --git a/handlers/groups/get.go b/handlers/groups/get.go new file mode 100644 index 0000000..45adc9a --- /dev/null +++ b/handlers/groups/get.go @@ -0,0 +1,41 @@ +package groups + +import ( + "fmt" + "git.macaw.me/inhosin/subhub/activitypub" + "git.macaw.me/inhosin/subhub/data" + "git.macaw.me/inhosin/subhub/data/models" + "git.macaw.me/inhosin/subhub/handlers" + "github.com/gin-gonic/gin" + "net/http" +) + +// Get returns a Group +// +// Expects a `{name}` url variable +// in the route: `/api/group/:name` +func Get(c *gin.Context) { + name := c.Param("name") + if name == "" { + c.String(http.StatusBadRequest, "Bad request! No name in URL") + return + } + group, err := models.GetGroup(data.GetDB(), name) + if err != nil { + c.String(http.StatusInternalServerError, err.Error()) + return + } + + if group == nil { + c.String(http.StatusNotFound, fmt.Sprintf("%s does not exits on server", name)) + return + } + + url := handlers.GetFullHostName() + "/activity/group/" + group.Slug + actor := activitypub.NewPerson(url) + actor.PreferredUsername = group.Name + actor.Name = group.Name + actor.Summary = group.Note + + c.JSON(http.StatusOK, actor) +} diff --git a/handlers/groups/post.go b/handlers/groups/post.go new file mode 100644 index 0000000..bf5ea6a --- /dev/null +++ b/handlers/groups/post.go @@ -0,0 +1,32 @@ +package groups + +import ( + "git.macaw.me/inhosin/subhub/data" + "git.macaw.me/inhosin/subhub/data/models" + "github.com/gin-gonic/gin" + "net/http" +) + +type createGroupRequest struct { + Name string `json:"name"` + Note string `json:"note"` +} + +type createGroupResponse struct { + Slug string `json:"slug"` +} + +// Create adds an group to the database +func Create(c *gin.Context) { + var body createGroupRequest + c.BindJSON(&body) + + db := data.GetDB() + slug, err := models.PutGroup(db, body.Name, body.Note) + if err != nil { + c.String(http.StatusInternalServerError, err.Error()) + return + } + resp := createGroupResponse{Slug: slug} + c.JSON(http.StatusOK, resp) +} diff --git a/handlers/path.go b/handlers/path.go new file mode 100644 index 0000000..80c6e4d --- /dev/null +++ b/handlers/path.go @@ -0,0 +1,7 @@ +package handlers + +// GetFullHostName return string +func GetFullHostName() string { + // TODO make it at config + return "http://" + "localhost" + ":8090" +} diff --git a/handlers/webfinder/README.md b/handlers/webfinder/README.md new file mode 100644 index 0000000..fc2f7c9 --- /dev/null +++ b/handlers/webfinder/README.md @@ -0,0 +1,23 @@ +# What's Webfinger? + +[Webfinger](https://en.wikipedia.org/wiki/WebFinger), a protocol for discovering objects on the server. It's used by Mastodon and is important for interop'ing with Mastodon (and most ActivityPub servers). + +It lives at a special route: `GET /.well-known/webfinger`. + +We can expect a webfinger response to always looks something like this: + +```json +{ + "subject": "acct:alice@my-example.com", + + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": "https://my-example.com/actor" + } + ] +} +``` + +In this case, `alice` is the ActivityPub Organization slug, and `my-example.com` is the domain of the server. diff --git a/handlers/webfinder/address.go b/handlers/webfinder/address.go new file mode 100644 index 0000000..5bd374c --- /dev/null +++ b/handlers/webfinder/address.go @@ -0,0 +1,40 @@ +package webfinder + +import ( + "git.macaw.me/inhosin/subhub/handlers" + "net/mail" + "strings" +) + +func newBadAddressError(address string) *badAddressError { + return &badAddressError{address: address} +} + +func slugOf(address string) string { + fragment := strings.Split(address, "@") + return fragment[0] +} + +func atAddress(address string) (*Resource, error) { + // foo@bar.org => foo + parser := mail.AddressParser{} + _, err := parser.Parse(address) + if err != nil { + return nil, newBadAddressError(address) + } + + // foo@bar.org => foo + slug := slugOf(address) + domain := handlers.GetFullHostName() + + return &Resource{ + Subject: address, + Links: []Link{ + { + Rel: "self", + Type: "application/activity+json", + HRef: domain + "/api/group/" + slug, + }, + }, + }, nil +} diff --git a/handlers/webfinder/models.go b/handlers/webfinder/models.go new file mode 100644 index 0000000..c6682bd --- /dev/null +++ b/handlers/webfinder/models.go @@ -0,0 +1,26 @@ +package webfinder + +type Resource struct { + Subject string `json:"subject,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Properties map[string]string `json:"properties,omitempty"` + Links []Link `json:"links"` +} + +type Link struct { + HRef string `json:"href"` + Type string `json:"type,omitempty"` + Rel string `json:"rel"` + Properties map[string]*string `json:"properties,omitempty"` + Titles map[string]string `json:"titles,omitempty"` +} + +type Rel string + +type badAddressError struct { + address string +} + +func (e *badAddressError) Error() string { + return "badly formatted address: " + e.address +} diff --git a/handlers/webfinder/webfinger.go b/handlers/webfinder/webfinger.go new file mode 100644 index 0000000..75aac1d --- /dev/null +++ b/handlers/webfinder/webfinger.go @@ -0,0 +1,50 @@ +package webfinder + +import ( + "errors" + "github.com/gin-gonic/gin" + "net/http" + "strings" +) + +func Get(c *gin.Context) { + res := c.Query("resource") + if res == "" { + c.String(http.StatusBadRequest, "Missing 'resouce' query parameter in webfinger") + return + } + + address, err := getAddress(res) + if err != nil { + c.String(http.StatusBadRequest, err.Error()) + } + // TODO find group??? + actor, err := atAddress(address) + if err != nil { + if _, ok := err.(*badAddressError); ok { + c.String(http.StatusBadRequest, "Incorrect address format") + return + } + + c.String(http.StatusBadRequest, err.Error()) + return + } + if actor == nil { + c.String(http.StatusBadRequest, "Account not found") + } + + // bytes, err := json.Marshal(actor) + // if err != nil { + // http.Error(w, err.Error(), http.StatusBadRequest) + // return + // } + c.JSON(http.StatusOK, actor) +} + +func getAddress(res string) (string, error) { + args := strings.Split(res, ":") + if args[0] != "acct" { + return "", errors.New("Resource didn`t start with acct") + } + return args[1], nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..3db32d5 --- /dev/null +++ b/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "git.macaw.me/inhosin/subhub/handlers/groups" + "git.macaw.me/inhosin/subhub/handlers/webfinder" + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" + "net/http" + "os" +) + +func init() { + + log.SetOutput(os.Stdout) +} + +func main() { + r := gin.Default() + r.GET("/.well-known/webfinger", webfinder.Get) + api := r.Group("/api") + { + api.GET("/api/group/:name", groups.Get) + api.POST("/api/group", groups.Create) + } + r.GET("/health", healthHandler) + + // Add support for PORT env + port := "8080" + if fromEnv := os.Getenv("PORT"); fromEnv != "" { + port = fromEnv + } + + log.WithField("port", port).Info("starting pubcast api server") + log.Fatal(r.Run(":" + port)) +} + +func healthHandler(c *gin.Context) { + c.String(http.StatusOK, "I`am alive.") +}