Skip to content

Commit

Permalink
Merge branch 'main' into fastinbox
Browse files Browse the repository at this point in the history
  • Loading branch information
dimkr committed Sep 26, 2023
2 parents 2f09765 + 026e970 commit 62ce44a
Show file tree
Hide file tree
Showing 49 changed files with 1,277 additions and 215 deletions.
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**/.git*
/README.md
/Dockerfile
/.dockerignore
/migrations/add.sh
/migrations/migrations.go
23 changes: 23 additions & 0 deletions .github/workflows/image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: image

on:
workflow_run:
workflows: [build]
types: [completed]
workflow_dispatch:

jobs:
image:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
pull: true
push: true
tags: ghcr.io/${{ github.repository }}:latest
35 changes: 35 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright 2023 Dima Krasner
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

FROM golang:1.21-alpine AS build
RUN apk add --no-cache gcc musl-dev openssl
COPY go.mod /src/
COPY go.sum /src/
WORKDIR /src
RUN go mod download
COPY migrations /src/migrations
RUN go generate ./migrations
COPY . /src
RUN go vet ./...
RUN go test ./... -failfast -vet off
RUN go build ./cmd/tootik

FROM alpine
RUN apk add --no-cache ca-certificates openssl
COPY --from=build /src/tootik /
COPY --from=build /src/LICENSE /
RUN adduser -D tootik
USER tootik
WORKDIR /tmp
ENTRYPOINT ["/tootik"]
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ or, to build a static executable:
* cfg/ contains global configuration parameters.
* logger/ contains logging utilities.

* test/ contains tests.

## Gemini Frontend

* /local shows a compact list of local posts; each entry contains a link to /view.
Expand All @@ -75,6 +77,8 @@ Users are authenticated using TLS client certificates; see [Gemini protocol spec
* /users/whisper creates a post visible to followers.
* /users/say creates a public post.
* /users/reply replies to a post.
* /users/edit edits a post.
* /users/delete deletes a post.
* /users/follow sends a follow request to a user.
* /users/unfollow deletes a follow request.
* /users/outbox is equivalent to /outbox but also includes a link to /users/follow or /users/unfollow.
Expand Down Expand Up @@ -133,6 +137,10 @@ User A is allowed to send a message to user B only if B follows A.
| Post | Post author | Mentions and followers of reply author |
| Public post | Post author | Mentions, followers of reply author and Public |

### Post Editing

/users/edit only changes the content and the last update timestamp of a post. It does **not** change the post audience and mentioned users.

## Implementation Details

### The "Nobody" User
Expand Down
7 changes: 5 additions & 2 deletions cmd/tootik/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/dimkr/tootik/cfg"
"github.com/dimkr/tootik/data"
"github.com/dimkr/tootik/fed"
"github.com/dimkr/tootik/front"
"github.com/dimkr/tootik/front/finger"
"github.com/dimkr/tootik/front/gemini"
"github.com/dimkr/tootik/front/gopher"
Expand Down Expand Up @@ -141,9 +142,11 @@ func main() {
wg.Done()
}()

handler := front.NewHandler()

wg.Add(1)
go func() {
if err := gemini.ListenAndServe(ctx, log, db, resolver, *gemAddr, *gemCert, *gemKey); err != nil {
if err := gemini.ListenAndServe(ctx, log, db, handler, resolver, *gemAddr, *gemCert, *gemKey); err != nil {
log.Error("Gemini listener has failed", "error", err)
}
cancel()
Expand All @@ -152,7 +155,7 @@ func main() {

wg.Add(1)
go func() {
if err := gopher.ListenAndServe(ctx, log, db, resolver, *gopherAddr); err != nil {
if err := gopher.ListenAndServe(ctx, log, handler, db, resolver, *gopherAddr); err != nil {
log.Error("Gopher listener has failed", "error", err)
}
cancel()
Expand Down
88 changes: 88 additions & 0 deletions fed/delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
Copyright 2023 Dima Krasner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package fed

import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/dimkr/tootik/ap"
)

func Delete(ctx context.Context, db *sql.DB, note *ap.Object) error {
delete, err := json.Marshal(ap.Activity{
Context: "https://www.w3.org/ns/activitystreams",
ID: note.ID + "#delete",
Type: ap.DeleteActivity,
Actor: note.AttributedTo,
Object: ap.Object{
Type: note.Type,
ID: note.ID,
},
To: note.To,
CC: note.CC,
})
if err != nil {
return fmt.Errorf("Failed to marshal delete: %w", err)
}

tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("Failed to begin transaction: %w", err)
}
defer tx.Rollback()

// mark this post as sent so recipients who haven't received it yet don't receive it
if _, err := tx.ExecContext(
ctx,
`UPDATE outbox SET sent = 1 WHERE activity->>'object.id' = ? and activity->>'type' = 'Create'`,
note.ID,
); err != nil {
return fmt.Errorf("Failed to insert delete activity: %w", err)
}

if _, err := tx.ExecContext(
ctx,
`DELETE FROM notes WHERE id = ?`,
note.ID,
); err != nil {
return fmt.Errorf("Failed to delete note: %w", err)
}

if _, err := tx.ExecContext(
ctx,
`DELETE FROM outbox WHERE activity->>'object.id' = ?`,
note.ID,
); err != nil {
return fmt.Errorf("Failed to delete activities: %w", err)
}

if _, err := tx.ExecContext(
ctx,
`INSERT INTO outbox (activity) VALUES (?)`,
string(delete),
); err != nil {
return fmt.Errorf("Failed to insert delete activity: %w", err)
}

if err := tx.Commit(); err != nil {
return fmt.Errorf("Failed to delete note: %w", err)
}

return nil
}
14 changes: 9 additions & 5 deletions fed/deliver.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import (
const (
batchSize = 16
deliveryRetryInterval = int64((time.Hour / 2) / time.Second)
maxDeliveryAttempts = 5
MaxDeliveryAttempts = 5
pollingInterval = time.Second * 5
deliveryTimeout = time.Minute * 5
maxDeliveryQueueSize = 128
Expand Down Expand Up @@ -63,7 +63,7 @@ func DeliverPosts(ctx context.Context, log *slog.Logger, db *sql.DB, resolver *R
func deliverPosts(ctx context.Context, log *slog.Logger, db *sql.DB, resolver *Resolver) error {
log.Debug("Polling delivery queue")

rows, err := db.QueryContext(ctx, `select outbox.attempts, outbox.activity, persons.actor from outbox join persons on persons.id = outbox.activity->>'actor' where outbox.sent = 0 and (outbox.attempts = 0 or (outbox.attempts < ? and outbox.last <= unixepoch() - ?)) order by outbox.attempts asc, outbox.last asc limit ?`, maxDeliveryAttempts, deliveryRetryInterval, batchSize)
rows, err := db.QueryContext(ctx, `select outbox.attempts, outbox.activity, persons.actor from outbox join persons on persons.id = outbox.activity->>'actor' where outbox.sent = 0 and (outbox.attempts = 0 or (outbox.attempts < ? and outbox.last <= unixepoch() - ?)) order by outbox.attempts asc, outbox.last asc limit ?`, MaxDeliveryAttempts, deliveryRetryInterval, batchSize)
if err != nil {
return fmt.Errorf("Failed to fetch posts to deliver: %w", err)
}
Expand Down Expand Up @@ -174,10 +174,14 @@ func deliver(ctx context.Context, log *slog.Logger, db *sql.DB, activity *ap.Act

if to, err := resolver.Resolve(ctx, log, db, actor, actorID, false); err != nil {
log.Warn("Failed to resolve a recipient", "to", actorID, "activity", activity.ID, "error", err)
anyFailed = true
if !errors.Is(err, ErrActorGone) && !errors.Is(err, ErrBlockedDomain) {
anyFailed = true
}
} else if err := Send(ctx, log, db, actor, resolver, to, buf); err != nil {
log.Warn("Failed to send a post", "to", actorID, "activity", activity.ID, "error", err)
anyFailed = true
if !errors.Is(err, ErrBlockedDomain) {
anyFailed = true
}
}

return true
Expand Down Expand Up @@ -209,7 +213,7 @@ func Deliver(ctx context.Context, log *slog.Logger, db *sql.DB, post *ap.Object,
}

var queueSize int
if err := db.QueryRowContext(ctx, `select count (*) from outbox where sent = 0 and attempts < ?`, maxDeliveryAttempts).Scan(&queueSize); err != nil {
if err := db.QueryRowContext(ctx, `select count (*) from outbox where sent = 0 and attempts < ?`, MaxDeliveryAttempts).Scan(&queueSize); err != nil {
return fmt.Errorf("Failed to query delivery queue size: %w", err)
}

Expand Down
84 changes: 84 additions & 0 deletions fed/edit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
Copyright 2023 Dima Krasner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package fed

import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"github.com/dimkr/tootik/ap"
"github.com/dimkr/tootik/cfg"
"time"
)

func Edit(ctx context.Context, db *sql.DB, note *ap.Object, newContent string) error {
now := time.Now()

note.Content = newContent
note.Updated = &now

body, err := json.Marshal(note)
if err != nil {
return fmt.Errorf("Failed to marshal note: %w", err)
}

updateID := fmt.Sprintf("https://%s/update/%x", cfg.Domain, sha256.Sum256([]byte(fmt.Sprintf("%s|%d", note.ID, now.Unix()))))

update, err := json.Marshal(ap.Activity{
Context: "https://www.w3.org/ns/activitystreams",
ID: updateID,
Type: ap.UpdateActivity,
Actor: note.AttributedTo,
Object: note,
To: note.To,
CC: note.CC,
})
if err != nil {
return fmt.Errorf("Failed to marshal update: %w", err)
}

tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("Failed to begin transaction: %w", err)
}
defer tx.Rollback()

if _, err := tx.ExecContext(
ctx,
`UPDATE notes SET object = ? WHERE id = ?`,
string(body),
note.ID,
); err != nil {
return fmt.Errorf("Failed to update note: %w", err)
}

if _, err := tx.ExecContext(
ctx,
`INSERT INTO outbox (activity) VALUES(?)`,
string(update),
); err != nil {
return fmt.Errorf("Failed to insert update activity: %w", err)
}

if err := tx.Commit(); err != nil {
return fmt.Errorf("Failed to update note: %w", err)
}

return nil
}
6 changes: 5 additions & 1 deletion fed/follow.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"fmt"
"github.com/dimkr/tootik/ap"
"github.com/dimkr/tootik/cfg"
"strings"
)

func Follow(ctx context.Context, follower *ap.Actor, followed string, db *sql.DB) error {
Expand All @@ -47,12 +48,15 @@ func Follow(ctx context.Context, follower *ap.Actor, followed string, db *sql.DB
return fmt.Errorf("Failed to marshal follow: %w", err)
}

isLocal := strings.HasPrefix(followed, fmt.Sprintf("https://%s/", cfg.Domain))

if _, err := db.ExecContext(
ctx,
`INSERT INTO follows (id, follower, followed) VALUES(?,?,?)`,
`INSERT INTO follows (id, follower, followed, accepted) VALUES(?,?,?,?)`,
followID,
follower.ID,
followed,
isLocal, // local follows don't need to be accepted
); err != nil {
return fmt.Errorf("Failed to insert follow: %w", err)
}
Expand Down
Loading

0 comments on commit 62ce44a

Please sign in to comment.