lexicons: add sh.tangled.string #420

closed
opened by anirudh.fi targeting master from push-lyrpkknpnrus
Changed files
+1493 -63
api
appview
config
db
middleware
oauth
handler
pages
templates
layouts
strings
user
fragments
state
strings
cmd
knotserver
lexicons
string
+232
api/tangled/cbor_gen.go
···
return nil
+
func (t *String) MarshalCBOR(w io.Writer) error {
+
if t == nil {
+
_, err := w.Write(cbg.CborNull)
+
return err
+
}
+
+
cw := cbg.NewCborWriter(w)
+
+
if _, err := cw.Write([]byte{165}); err != nil {
+
return err
+
}
+
+
// t.LexiconTypeID (string) (string)
+
if len("$type") > 1000000 {
+
return xerrors.Errorf("Value in field \"$type\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("$type")); err != nil {
+
return err
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("sh.tangled.string")); err != nil {
+
return err
+
}
+
+
// t.Contents (string) (string)
+
if len("contents") > 1000000 {
+
return xerrors.Errorf("Value in field \"contents\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("contents")); err != nil {
+
return err
+
}
+
+
if len(t.Contents) > 1000000 {
+
return xerrors.Errorf("Value in field t.Contents was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Contents)); err != nil {
+
return err
+
}
+
+
// t.Filename (string) (string)
+
if len("filename") > 1000000 {
+
return xerrors.Errorf("Value in field \"filename\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("filename")); err != nil {
+
return err
+
}
+
+
if len(t.Filename) > 1000000 {
+
return xerrors.Errorf("Value in field t.Filename was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Filename)); err != nil {
+
return err
+
}
+
+
// t.CreatedAt (string) (string)
+
if len("createdAt") > 1000000 {
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
+
return err
+
}
+
+
if len(t.CreatedAt) > 1000000 {
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
+
return err
+
}
+
+
// t.Description (string) (string)
+
if len("description") > 1000000 {
+
return xerrors.Errorf("Value in field \"description\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("description")); err != nil {
+
return err
+
}
+
+
if len(t.Description) > 1000000 {
+
return xerrors.Errorf("Value in field t.Description was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Description)); err != nil {
+
return err
+
}
+
return nil
+
}
+
+
func (t *String) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = String{}
+
+
cr := cbg.NewCborReader(r)
+
+
maj, extra, err := cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
defer func() {
+
if err == io.EOF {
+
err = io.ErrUnexpectedEOF
+
}
+
}()
+
+
if maj != cbg.MajMap {
+
return fmt.Errorf("cbor input should be of type map")
+
}
+
+
if extra > cbg.MaxLength {
+
return fmt.Errorf("String: map struct too large (%d)", extra)
+
}
+
+
n := extra
+
+
nameBuf := make([]byte, 11)
+
for i := uint64(0); i < n; i++ {
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+
if err != nil {
+
return err
+
}
+
+
if !ok {
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
continue
+
}
+
+
switch string(nameBuf[:nameLen]) {
+
// t.LexiconTypeID (string) (string)
+
case "$type":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.LexiconTypeID = string(sval)
+
}
+
// t.Contents (string) (string)
+
case "contents":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Contents = string(sval)
+
}
+
// t.Filename (string) (string)
+
case "filename":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Filename = string(sval)
+
}
+
// t.CreatedAt (string) (string)
+
case "createdAt":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.CreatedAt = string(sval)
+
}
+
// t.Description (string) (string)
+
case "description":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Description = string(sval)
+
}
+
+
default:
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
}
+
}
+
+
return nil
+
}
+25
api/tangled/tangledstring.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.string
+
+
import (
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
StringNSID = "sh.tangled.string"
+
)
+
+
func init() {
+
util.RegisterType("sh.tangled.string", &String{})
+
} //
+
// RECORDTYPE: String
+
type String struct {
+
LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"`
+
Contents string `json:"contents" cborgen:"contents"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
Description string `json:"description" cborgen:"description"`
+
Filename string `json:"filename" cborgen:"filename"`
+
}
+1
cmd/gen.go
···
tangled.RepoPullStatus{},
tangled.Spindle{},
tangled.SpindleMember{},
+
tangled.String{},
); err != nil {
panic(err)
}
+40
lexicons/string/string.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.string",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": [
+
"filename",
+
"description",
+
"createdAt",
+
"contents"
+
],
+
"properties": {
+
"filename": {
+
"type": "string",
+
"maxGraphemes": 140,
+
"minGraphemes": 1
+
},
+
"description": {
+
"type": "string",
+
"maxGraphemes": 280
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"contents": {
+
"type": "string",
+
"minGraphemes": 1
+
}
+
}
+
}
+
}
+
}
+
}
+15
appview/db/db.go
···
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
+
create table if not exists strings (
+
-- identifiers
+
did text not null,
+
rkey text not null,
+
+
-- content
+
filename text not null,
+
description text,
+
content text not null,
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
edited text,
+
+
primary key (did, rkey)
+
);
+
create table if not exists migrations (
id integer primary key autoincrement,
name text unique
+251
appview/db/strings.go
···
+
package db
+
+
import (
+
"bytes"
+
"database/sql"
+
"errors"
+
"fmt"
+
"io"
+
"strings"
+
"time"
+
"unicode/utf8"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
)
+
+
type String struct {
+
Did syntax.DID
+
Rkey string
+
+
Filename string
+
Description string
+
Contents string
+
Created time.Time
+
Edited *time.Time
+
}
+
+
func (s *String) StringAt() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
+
}
+
+
type StringStats struct {
+
LineCount uint64
+
ByteCount uint64
+
}
+
+
func (s String) Stats() StringStats {
+
lineCount, err := countLines(strings.NewReader(s.Contents))
+
if err != nil {
+
// non-fatal
+
// TODO: log this?
+
}
+
+
return StringStats{
+
LineCount: uint64(lineCount),
+
ByteCount: uint64(len(s.Contents)),
+
}
+
}
+
+
func (s String) Validate() error {
+
var err error
+
+
if !strings.Contains(s.Filename, ".") {
+
err = errors.Join(err, fmt.Errorf("missing filename extension"))
+
}
+
+
if strings.HasSuffix(s.Filename, ".") {
+
err = errors.Join(err, fmt.Errorf("filename ends with `.`"))
+
}
+
+
if utf8.RuneCountInString(s.Filename) > 140 {
+
err = errors.Join(err, fmt.Errorf("filename too long"))
+
}
+
+
if utf8.RuneCountInString(s.Description) > 280 {
+
err = errors.Join(err, fmt.Errorf("description too long"))
+
}
+
+
if len(s.Contents) == 0 {
+
err = errors.Join(err, fmt.Errorf("contents is empty"))
+
}
+
+
return err
+
}
+
+
func (s *String) AsRecord() tangled.String {
+
return tangled.String{
+
Filename: s.Filename,
+
Description: s.Description,
+
Contents: s.Contents,
+
CreatedAt: s.Created.Format(time.RFC3339),
+
}
+
}
+
+
func StringFromRecord(did, rkey string, record tangled.String) String {
+
created, err := time.Parse(record.CreatedAt, time.RFC3339)
+
if err != nil {
+
created = time.Now()
+
}
+
return String{
+
Did: syntax.DID(did),
+
Rkey: rkey,
+
Filename: record.Filename,
+
Description: record.Description,
+
Contents: record.Contents,
+
Created: created,
+
}
+
}
+
+
func AddString(e Execer, s String) error {
+
_, err := e.Exec(
+
`insert into strings (
+
did,
+
rkey,
+
filename,
+
description,
+
content,
+
created,
+
edited
+
)
+
values (?, ?, ?, ?, ?, ?, null)
+
on conflict(did, rkey) do update set
+
filename = excluded.filename,
+
description = excluded.description,
+
content = excluded.content,
+
edited = case
+
when
+
strings.content != excluded.content
+
or strings.filename != excluded.filename
+
or strings.description != excluded.description then ?
+
else strings.edited
+
end`,
+
s.Did,
+
s.Rkey,
+
s.Filename,
+
s.Description,
+
s.Contents,
+
s.Created.Format(time.RFC3339),
+
time.Now().Format(time.RFC3339),
+
)
+
return err
+
}
+
+
func GetStrings(e Execer, filters ...filter) ([]String, error) {
+
var all []String
+
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
+
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
+
query := fmt.Sprintf(`select
+
did,
+
rkey,
+
filename,
+
description,
+
content,
+
created,
+
edited
+
from strings %s`,
+
whereClause,
+
)
+
+
rows, err := e.Query(query, args...)
+
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
for rows.Next() {
+
var s String
+
var createdAt string
+
var editedAt sql.NullString
+
+
if err := rows.Scan(
+
&s.Did,
+
&s.Rkey,
+
&s.Filename,
+
&s.Description,
+
&s.Contents,
+
&createdAt,
+
&editedAt,
+
); err != nil {
+
return nil, err
+
}
+
+
s.Created, err = time.Parse(time.RFC3339, createdAt)
+
if err != nil {
+
s.Created = time.Now()
+
}
+
+
if editedAt.Valid {
+
e, err := time.Parse(time.RFC3339, editedAt.String)
+
if err != nil {
+
e = time.Now()
+
}
+
s.Edited = &e
+
}
+
+
all = append(all, s)
+
}
+
+
if err := rows.Err(); err != nil {
+
return nil, err
+
}
+
+
return all, nil
+
}
+
+
func DeleteString(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
+
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
+
query := fmt.Sprintf(`delete from strings %s`, whereClause)
+
+
_, err := e.Exec(query, args...)
+
return err
+
}
+
+
func countLines(r io.Reader) (int, error) {
+
buf := make([]byte, 32*1024)
+
bufLen := 0
+
count := 0
+
nl := []byte{'\n'}
+
+
for {
+
c, err := r.Read(buf)
+
if c > 0 {
+
bufLen += c
+
}
+
count += bytes.Count(buf[:c], nl)
+
+
switch {
+
case err == io.EOF:
+
/* handle last line not having a newline at the end */
+
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
+
count++
+
}
+
return count, nil
+
case err != nil:
+
return 0, err
+
}
+
}
+
}
+449
appview/strings/strings.go
···
+
package strings
+
+
import (
+
"fmt"
+
"log/slog"
+
"net/http"
+
"path"
+
"slices"
+
"strconv"
+
"strings"
+
"time"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview/config"
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/middleware"
+
"tangled.sh/tangled.sh/core/appview/oauth"
+
"tangled.sh/tangled.sh/core/appview/pages"
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
+
"tangled.sh/tangled.sh/core/eventconsumer"
+
"tangled.sh/tangled.sh/core/idresolver"
+
"tangled.sh/tangled.sh/core/rbac"
+
"tangled.sh/tangled.sh/core/tid"
+
+
"github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/identity"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
lexutil "github.com/bluesky-social/indigo/lex/util"
+
"github.com/go-chi/chi/v5"
+
)
+
+
type Strings struct {
+
Db *db.DB
+
OAuth *oauth.OAuth
+
Pages *pages.Pages
+
Config *config.Config
+
Enforcer *rbac.Enforcer
+
IdResolver *idresolver.Resolver
+
Logger *slog.Logger
+
Knotstream *eventconsumer.Consumer
+
}
+
+
func (s *Strings) Router(mw *middleware.Middleware) http.Handler {
+
r := chi.NewRouter()
+
+
r.
+
With(mw.ResolveIdent()).
+
Route("/{user}", func(r chi.Router) {
+
r.Get("/", s.dashboard)
+
+
r.Route("/{rkey}", func(r chi.Router) {
+
r.Get("/", s.contents)
+
r.Delete("/", s.delete)
+
r.Get("/raw", s.contents)
+
r.Get("/edit", s.edit)
+
r.Post("/edit", s.edit)
+
r.
+
With(middleware.AuthMiddleware(s.OAuth)).
+
Post("/comment", s.comment)
+
})
+
})
+
+
r.
+
With(middleware.AuthMiddleware(s.OAuth)).
+
Route("/new", func(r chi.Router) {
+
r.Get("/", s.create)
+
r.Post("/", s.create)
+
})
+
+
return r
+
}
+
+
func (s *Strings) contents(w http.ResponseWriter, r *http.Request) {
+
l := s.Logger.With("handler", "contents")
+
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
+
if !ok {
+
l.Error("malformed middleware")
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
l = l.With("did", id.DID, "handle", id.Handle)
+
+
rkey := chi.URLParam(r, "rkey")
+
if rkey == "" {
+
l.Error("malformed url, empty rkey")
+
w.WriteHeader(http.StatusBadRequest)
+
return
+
}
+
l = l.With("rkey", rkey)
+
+
strings, err := db.GetStrings(
+
s.Db,
+
db.FilterEq("did", id.DID),
+
db.FilterEq("rkey", rkey),
+
)
+
if err != nil {
+
l.Error("failed to fetch string", "err", err)
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
if len(strings) != 1 {
+
l.Error("incorrect number of records returned", "len(strings)", len(strings))
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
string := strings[0]
+
+
if path.Base(r.URL.Path) == "raw" {
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+
if string.Filename != "" {
+
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename))
+
}
+
w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents)))
+
+
_, err = w.Write([]byte(string.Contents))
+
if err != nil {
+
l.Error("failed to write raw response", "err", err)
+
}
+
return
+
}
+
+
var showRendered, renderToggle bool
+
if markup.GetFormat(string.Filename) == markup.FormatMarkdown {
+
renderToggle = true
+
showRendered = r.URL.Query().Get("code") != "true"
+
}
+
+
s.Pages.SingleString(w, pages.SingleStringParams{
+
LoggedInUser: s.OAuth.GetUser(r),
+
RenderToggle: renderToggle,
+
ShowRendered: showRendered,
+
String: string,
+
Stats: string.Stats(),
+
Owner: id,
+
})
+
}
+
+
func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
+
l := s.Logger.With("handler", "dashboard")
+
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
+
if !ok {
+
l.Error("malformed middleware")
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
l = l.With("did", id.DID, "handle", id.Handle)
+
+
all, err := db.GetStrings(
+
s.Db,
+
db.FilterEq("did", id.DID),
+
)
+
if err != nil {
+
l.Error("failed to fetch strings", "err", err)
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
+
slices.SortFunc(all, func(a, b db.String) int {
+
if a.Created.After(b.Created) {
+
return -1
+
} else {
+
return 1
+
}
+
})
+
+
profile, err := db.GetProfile(s.Db, id.DID.String())
+
if err != nil {
+
l.Error("failed to fetch user profile", "err", err)
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
loggedInUser := s.OAuth.GetUser(r)
+
followStatus := db.IsNotFollowing
+
if loggedInUser != nil {
+
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
+
}
+
+
followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String())
+
if err != nil {
+
l.Error("failed to get follow stats", "err", err)
+
}
+
+
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
+
LoggedInUser: s.OAuth.GetUser(r),
+
Card: pages.ProfileCard{
+
UserDid: id.DID.String(),
+
UserHandle: id.Handle.String(),
+
Profile: profile,
+
FollowStatus: followStatus,
+
Followers: followers,
+
Following: following,
+
},
+
Strings: all,
+
})
+
}
+
+
func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
+
l := s.Logger.With("handler", "edit")
+
+
user := s.OAuth.GetUser(r)
+
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
+
if !ok {
+
l.Error("malformed middleware")
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
l = l.With("did", id.DID, "handle", id.Handle)
+
+
rkey := chi.URLParam(r, "rkey")
+
if rkey == "" {
+
l.Error("malformed url, empty rkey")
+
w.WriteHeader(http.StatusBadRequest)
+
return
+
}
+
l = l.With("rkey", rkey)
+
+
// get the string currently being edited
+
all, err := db.GetStrings(
+
s.Db,
+
db.FilterEq("did", id.DID),
+
db.FilterEq("rkey", rkey),
+
)
+
if err != nil {
+
l.Error("failed to fetch string", "err", err)
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
if len(all) != 1 {
+
l.Error("incorrect number of records returned", "len(strings)", len(all))
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
first := all[0]
+
+
// verify that the logged in user owns this string
+
if user.Did != id.DID.String() {
+
l.Error("unauthorized request", "expected", id.DID, "got", user.Did)
+
w.WriteHeader(http.StatusUnauthorized)
+
return
+
}
+
+
switch r.Method {
+
case http.MethodGet:
+
// return the form with prefilled fields
+
s.Pages.PutString(w, pages.PutStringParams{
+
LoggedInUser: s.OAuth.GetUser(r),
+
Action: "edit",
+
String: first,
+
})
+
case http.MethodPost:
+
fail := func(msg string, err error) {
+
l.Error(msg, "err", err)
+
s.Pages.Notice(w, "error", msg)
+
}
+
+
filename := r.FormValue("filename")
+
if filename == "" {
+
fail("Empty filename.", nil)
+
return
+
}
+
if !strings.Contains(filename, ".") {
+
// TODO: make this a htmx form validation
+
fail("No extension provided for filename.", nil)
+
return
+
}
+
+
content := r.FormValue("content")
+
if content == "" {
+
fail("Empty contents.", nil)
+
return
+
}
+
+
description := r.FormValue("description")
+
+
// construct new string from form values
+
entry := db.String{
+
Did: first.Did,
+
Rkey: first.Rkey,
+
Filename: filename,
+
Description: description,
+
Contents: content,
+
Created: first.Created,
+
}
+
+
record := entry.AsRecord()
+
+
client, err := s.OAuth.AuthorizedClient(r)
+
if err != nil {
+
fail("Failed to create record.", err)
+
return
+
}
+
+
// first replace the existing record in the PDS
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
+
if err != nil {
+
fail("Failed to updated existing record.", err)
+
return
+
}
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
+
Collection: tangled.StringNSID,
+
Repo: entry.Did.String(),
+
Rkey: entry.Rkey,
+
SwapRecord: ex.Cid,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &record,
+
},
+
})
+
if err != nil {
+
fail("Failed to updated existing record.", err)
+
return
+
}
+
l := l.With("aturi", resp.Uri)
+
l.Info("edited string")
+
+
// if that went okay, updated the db
+
if err = db.AddString(s.Db, entry); err != nil {
+
fail("Failed to update string.", err)
+
return
+
}
+
+
// if that went okay, redir to the string
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
+
}
+
+
}
+
+
func (s *Strings) create(w http.ResponseWriter, r *http.Request) {
+
l := s.Logger.With("handler", "create")
+
user := s.OAuth.GetUser(r)
+
+
switch r.Method {
+
case http.MethodGet:
+
s.Pages.PutString(w, pages.PutStringParams{
+
LoggedInUser: s.OAuth.GetUser(r),
+
Action: "new",
+
})
+
case http.MethodPost:
+
fail := func(msg string, err error) {
+
l.Error(msg, "err", err)
+
s.Pages.Notice(w, "error", msg)
+
}
+
+
filename := r.FormValue("filename")
+
if filename == "" {
+
fail("Empty filename.", nil)
+
return
+
}
+
if !strings.Contains(filename, ".") {
+
// TODO: make this a htmx form validation
+
fail("No extension provided for filename.", nil)
+
return
+
}
+
+
content := r.FormValue("content")
+
if content == "" {
+
fail("Empty contents.", nil)
+
return
+
}
+
+
description := r.FormValue("description")
+
+
string := db.String{
+
Did: syntax.DID(user.Did),
+
Rkey: tid.TID(),
+
Filename: filename,
+
Description: description,
+
Contents: content,
+
Created: time.Now(),
+
}
+
+
record := string.AsRecord()
+
+
client, err := s.OAuth.AuthorizedClient(r)
+
if err != nil {
+
fail("Failed to create record.", err)
+
return
+
}
+
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
+
Collection: tangled.StringNSID,
+
Repo: user.Did,
+
Rkey: string.Rkey,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &record,
+
},
+
})
+
if err != nil {
+
fail("Failed to create record.", err)
+
return
+
}
+
l := l.With("aturi", resp.Uri)
+
l.Info("created record")
+
+
// insert into DB
+
if err = db.AddString(s.Db, string); err != nil {
+
fail("Failed to create string.", err)
+
return
+
}
+
+
// successful
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
+
}
+
}
+
+
func (s *Strings) delete(w http.ResponseWriter, r *http.Request) {
+
l := s.Logger.With("handler", "create")
+
user := s.OAuth.GetUser(r)
+
fail := func(msg string, err error) {
+
l.Error(msg, "err", err)
+
s.Pages.Notice(w, "error", msg)
+
}
+
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
+
if !ok {
+
l.Error("malformed middleware")
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
l = l.With("did", id.DID, "handle", id.Handle)
+
+
rkey := chi.URLParam(r, "rkey")
+
if rkey == "" {
+
l.Error("malformed url, empty rkey")
+
w.WriteHeader(http.StatusBadRequest)
+
return
+
}
+
+
if user.Did != id.DID.String() {
+
fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
+
return
+
}
+
+
if err := db.DeleteString(
+
s.Db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("rkey", rkey),
+
); err != nil {
+
fail("Failed to delete string.", err)
+
return
+
}
+
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
+
}
+
+
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+
}
+85
appview/pages/templates/strings/string.html
···
+
{{ define "title" }}{{ .String.Filename }} · by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }}
+
+
{{ define "extrameta" }}
+
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
+
<meta property="og:title" content="{{ .String.Filename }} · by {{ $ownerId }}" />
+
<meta property="og:type" content="object" />
+
<meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
+
<meta property="og:description" content="{{ .String.Description }}" />
+
{{ end }}
+
+
{{ define "topbar" }}
+
{{ template "layouts/topbar" $ }}
+
{{ end }}
+
+
{{ define "content" }}
+
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
+
<section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
+
<div class="text-lg flex items-center justify-between">
+
<div>
+
<a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a>
+
<span class="select-none">/</span>
+
<a href="/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
+
</div>
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
+
<div class="flex gap-2 text-base">
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
+
hx-boost="true"
+
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
+
{{ i "pencil" "size-4" }}
+
<span class="hidden md:inline">edit</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
+
<button
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2"
+
title="Delete string"
+
hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/"
+
hx-swap="none"
+
hx-confirm="Are you sure you want to delete the gist `{{ .String.Filename }}`?"
+
>
+
{{ i "trash-2" "size-4" }}
+
<span class="hidden md:inline">delete</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
</div>
+
{{ end }}
+
</div>
+
<span class="flex items-center">
+
{{ with .String.Description }}
+
{{ . }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+
{{ end }}
+
+
{{ with .String.Edited }}
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
+
{{ else }}
+
{{ template "repo/fragments/shortTimeAgo" .String.Created }}
+
{{ end }}
+
</span>
+
</section>
+
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white">
+
<div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
+
<span>{{ .String.Filename }}</span>
+
<div>
+
<span>{{ .Stats.LineCount }} lines</span>
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+
<span>{{ byteFmt .Stats.ByteCount }}</span>
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a>
+
{{ if .RenderToggle }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+
<a href="?code={{ .ShowRendered }}" hx-boost="true">
+
view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}
+
</a>
+
{{ end }}
+
</div>
+
</div>
+
<div class="overflow-auto relative">
+
{{ if .ShowRendered }}
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
+
{{ else }}
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
+
{{ end }}
+
</div>
+
</section>
+
{{ end }}
+56
appview/ingester.go
···
err = i.ingestSpindleMember(e)
case tangled.SpindleNSID:
err = i.ingestSpindle(e)
+
case tangled.StringNSID:
+
err = i.ingestString(e)
}
l = i.Logger.With("nsid", e.Commit.Collection)
}
···
return nil
}
+
+
func (i *Ingester) ingestString(e *models.Event) error {
+
did := e.Did
+
rkey := e.Commit.RKey
+
+
var err error
+
+
l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
+
l.Info("ingesting record")
+
+
ddb, ok := i.Db.Execer.(*db.DB)
+
if !ok {
+
return fmt.Errorf("failed to index string record, invalid db cast")
+
}
+
+
switch e.Commit.Operation {
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
+
raw := json.RawMessage(e.Commit.Record)
+
record := tangled.String{}
+
err = json.Unmarshal(raw, &record)
+
if err != nil {
+
l.Error("invalid record", "err", err)
+
return err
+
}
+
+
string := db.StringFromRecord(did, rkey, record)
+
+
if err = string.Validate(); err != nil {
+
l.Error("invalid record", "err", err)
+
return err
+
}
+
+
if err = db.AddString(ddb, string); err != nil {
+
l.Error("failed to add string", "err", err)
+
return err
+
}
+
+
return nil
+
+
case models.CommitOperationDelete:
+
if err := db.DeleteString(
+
ddb,
+
db.FilterEq("did", did),
+
db.FilterEq("rkey", rkey),
+
); err != nil {
+
l.Error("failed to delete", "err", err)
+
return fmt.Errorf("failed to delete string record: %w", err)
+
}
+
+
return nil
+
}
+
+
return nil
+
}
+2 -10
appview/middleware/middleware.go
···
}
}
-
func StripLeadingAt(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-
path := req.URL.EscapedPath()
-
if strings.HasPrefix(path, "/@") {
-
req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@")
-
}
-
next.ServeHTTP(w, req)
-
})
-
}
-
func (mw Middleware) ResolveIdent() middlewareFunc {
excluded := []string{"favicon.ico"}
···
return
}
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
+
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
if err != nil {
// invalid did or handle
+89
appview/pages/templates/strings/fragments/form.html
···
+
{{ define "strings/fragments/form" }}
+
<form
+
{{ if eq .Action "new" }}
+
hx-post="/strings/new"
+
{{ else }}
+
hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit"
+
{{ end }}
+
hx-indicator="#new-button"
+
class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded"
+
hx-swap="none">
+
<div class="flex flex-col md:flex-row md:items-center gap-2">
+
<input
+
type="text"
+
id="filename"
+
name="filename"
+
placeholder="Filename with extension"
+
required
+
value="{{ .String.Filename }}"
+
class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
+
>
+
<input
+
type="text"
+
id="description"
+
name="description"
+
value="{{ .String.Description }}"
+
placeholder="Description ..."
+
class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
+
>
+
</div>
+
<textarea
+
name="content"
+
id="content-textarea"
+
wrap="off"
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
+
rows="20"
+
placeholder="Paste your string here!"
+
required>{{ .String.Contents }}</textarea>
+
<div class="flex justify-between items-center">
+
<div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400">
+
<span id="line-count">0 lines</span>
+
<span class="select-none px-1 [&:before]:content-['·']"></span>
+
<span id="byte-count">0 bytes</span>
+
</div>
+
<div id="actions" class="flex gap-2 items-center">
+
{{ if eq .Action "edit" }}
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 "
+
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}">
+
{{ i "x" "size-4" }}
+
<span class="hidden md:inline">cancel</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
+
{{ end }}
+
<button
+
type="submit"
+
id="new-button"
+
class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group"
+
>
+
<span class="inline-flex items-center gap-2">
+
{{ i "arrow-up" "w-4 h-4" }}
+
publish
+
</span>
+
<span class="pl-2 hidden group-[.htmx-request]:inline">
+
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
+
</span>
+
</button>
+
</div>
+
</div>
+
<script>
+
(function() {
+
const textarea = document.getElementById('content-textarea');
+
const lineCount = document.getElementById('line-count');
+
const byteCount = document.getElementById('byte-count');
+
function updateStats() {
+
const content = textarea.value;
+
const lines = content === '' ? 0 : content.split('\n').length;
+
const bytes = new TextEncoder().encode(content).length;
+
lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`;
+
byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`;
+
}
+
textarea.addEventListener('input', updateStats);
+
textarea.addEventListener('paste', () => {
+
setTimeout(updateStats, 0);
+
});
+
updateStats();
+
})();
+
</script>
+
<div id="error" class="error dark:text-red-400"></div>
+
</form>
+
{{ end }}
+17
appview/pages/templates/strings/put.html
···
+
{{ define "title" }}publish a new string{{ end }}
+
+
{{ define "topbar" }}
+
{{ template "layouts/topbar" $ }}
+
{{ end }}
+
+
{{ define "content" }}
+
<div class="px-6 py-2 mb-4">
+
{{ if eq .Action "new" }}
+
<p class="text-xl font-bold dark:text-white">Create a new string</p>
+
<p class="">Store and share code snippets with ease.</p>
+
{{ else }}
+
<p class="text-xl font-bold dark:text-white">Edit string</p>
+
{{ end }}
+
</div>
+
{{ template "strings/fragments/form" . }}
+
{{ end }}
+1
appview/state/state.go
···
tangled.ActorProfileNSID,
tangled.SpindleMemberNSID,
tangled.SpindleNSID,
+
tangled.StringNSID,
},
nil,
slog.Default(),
-8
knotserver/file.go
···
"tangled.sh/tangled.sh/core/types"
)
-
func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) {
-
data["files"] = files
-
-
writeJSON(w, data)
-
return
-
}
-
func countLines(r io.Reader) (int, error) {
buf := make([]byte, 32*1024)
bufLen := 0
···
resp.Lines = lc
writeJSON(w, resp)
-
return
}
+57
appview/pages/templates/strings/dashboard.html
···
+
{{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }}
+
+
{{ define "extrameta" }}
+
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
+
<meta property="og:type" content="profile" />
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
+
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
+
{{ end }}
+
+
+
{{ define "content" }}
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
+
<div class="md:col-span-3 order-1 md:order-1">
+
{{ template "user/fragments/profileCard" .Card }}
+
</div>
+
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
+
{{ block "allStrings" . }}{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
{{ define "allStrings" }}
+
<p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p>
+
<div id="strings" class="grid grid-cols-1 gap-4 mb-6">
+
{{ range .Strings }}
+
{{ template "singleString" (list $ .) }}
+
{{ else }}
+
<p class="px-6 dark:text-white">This user does not have any strings yet.</p>
+
{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "singleString" }}
+
{{ $root := index . 0 }}
+
{{ $s := index . 1 }}
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
+
<div class="font-medium dark:text-white flex gap-2 items-center">
+
<a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
+
</div>
+
{{ with $s.Description }}
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
+
{{ . }}
+
</div>
+
{{ end }}
+
+
{{ $stat := $s.Stats }}
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto">
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
+
<span class="select-none [&:before]:content-['·']"></span>
+
{{ with $s.Edited }}
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
+
{{ else }}
+
{{ template "repo/fragments/shortTimeAgo" $s.Created }}
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+1 -3
appview/pages/templates/user/fragments/profileCard.html
···
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
<div id="avatar" class="col-span-1 flex justify-center items-center">
-
{{ if .AvatarUri }}
<div class="w-3/4 aspect-square relative">
-
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" />
+
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" />
</div>
-
{{ end }}
</div>
<div class="col-span-2">
<p title="{{ didOrHandle .UserDid .UserHandle }}"
-16
appview/state/profile.go
···
package state
import (
-
"crypto/hmac"
-
"crypto/sha256"
-
"encoding/hex"
"fmt"
"log"
"net/http"
···
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
}
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
s.pages.ProfilePage(w, pages.ProfilePageParams{
LoggedInUser: loggedInUser,
Repos: pinnedRepos,
···
Card: pages.ProfileCard{
UserDid: ident.DID.String(),
UserHandle: ident.Handle.String(),
-
AvatarUri: profileAvatarUri,
Profile: profile,
FollowStatus: followStatus,
Followers: followers,
···
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
}
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
-
s.pages.ReposPage(w, pages.ReposPageParams{
LoggedInUser: loggedInUser,
Repos: repos,
···
Card: pages.ProfileCard{
UserDid: ident.DID.String(),
UserHandle: ident.Handle.String(),
-
AvatarUri: profileAvatarUri,
Profile: profile,
FollowStatus: followStatus,
Followers: followers,
···
})
}
-
func (s *State) GetAvatarUri(handle string) string {
-
secret := s.config.Avatar.SharedSecret
-
h := hmac.New(sha256.New, []byte(secret))
-
h.Write([]byte(handle))
-
signature := hex.EncodeToString(h.Sum(nil))
-
return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle)
-
}
-
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
user := s.oauth.GetUser(r)
+20 -3
appview/pages/templates/layouts/topbar.html
···
<div id="right-items" class="flex items-center gap-4">
{{ with .LoggedInUser }}
-
<a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white">
-
{{ i "plus" "w-4 h-4" }}
-
</a>
+
{{ block "newButton" . }} {{ end }}
{{ block "dropDown" . }} {{ end }}
{{ else }}
<a href="/login">login</a>
···
</nav>
{{ end }}
+
{{ define "newButton" }}
+
<details class="relative inline-block text-left">
+
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
+
{{ i "plus" "w-4 h-4" }} new
+
</summary>
+
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
+
<a href="/repo/new" class="flex items-center gap-2">
+
{{ i "book-plus" "w-4 h-4" }}
+
new repository
+
</a>
+
<a href="/strings/new" class="flex items-center gap-2">
+
{{ i "line-squiggle" "w-4 h-4" }}
+
new string
+
</a>
+
</div>
+
</details>
+
{{ end }}
+
{{ define "dropDown" }}
<details class="relative inline-block text-left">
<summary
···
>
<a href="/{{ $user }}">profile</a>
<a href="/{{ $user }}?tab=repos">repositories</a>
+
<a href="/strings/{{ $user }}">strings</a>
<a href="/knots">knots</a>
<a href="/spindles">spindles</a>
<a href="/settings">settings</a>
+22 -22
flake.lock
···
{
"nodes": {
+
"flake-utils": {
+
"inputs": {
+
"systems": "systems"
+
},
+
"locked": {
+
"lastModified": 1694529238,
+
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
+
"owner": "numtide",
+
"repo": "flake-utils",
+
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
+
"type": "github"
+
},
+
"original": {
+
"owner": "numtide",
+
"repo": "flake-utils",
+
"type": "github"
+
}
+
},
"gitignore": {
"inputs": {
"nixpkgs": [
···
"type": "github"
}
},
-
"flake-utils": {
-
"inputs": {
-
"systems": "systems"
-
},
-
"locked": {
-
"lastModified": 1694529238,
-
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
-
"owner": "numtide",
-
"repo": "flake-utils",
-
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
-
"type": "github"
-
},
-
"original": {
-
"owner": "numtide",
-
"repo": "flake-utils",
-
"type": "github"
-
}
-
},
"gomod2nix": {
"inputs": {
"flake-utils": "flake-utils",
···
"lucide-src": {
"flake": false,
"locked": {
-
"lastModified": 1742302029,
-
"narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=",
+
"lastModified": 1754044466,
+
"narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=",
"type": "tarball",
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
},
"original": {
"type": "tarball",
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
}
},
"nixpkgs": {
+1 -1
flake.nix
···
flake = false;
};
lucide-src = {
-
url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip";
+
url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip";
flake = false;
};
inter-fonts-src = {
+3
appview/config/config.go
···
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
Dev bool `env:"DEV, default=false"`
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
+
+
// temporarily, to add users to default spindle
+
AppPassword string `env:"APP_PASSWORD"`
}
type OAuthConfig struct {
+126
appview/oauth/handler/handler.go
···
package oauth
import (
+
"bytes"
+
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strings"
+
"time"
"github.com/go-chi/chi/v5"
"github.com/gorilla/sessions"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/posthog/posthog-go"
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
+
tangled "tangled.sh/tangled.sh/core/api/tangled"
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
···
"tangled.sh/tangled.sh/core/idresolver"
"tangled.sh/tangled.sh/core/knotclient"
"tangled.sh/tangled.sh/core/rbac"
+
"tangled.sh/tangled.sh/core/tid"
)
const (
···
log.Println("session saved successfully")
go o.addToDefaultKnot(oauthRequest.Did)
+
go o.addToDefaultSpindle(oauthRequest.Did)
if !o.config.Core.Dev {
err = o.posthog.Enqueue(posthog.Capture{
···
return pubKey, nil
}
+
func (o *OAuthHandler) addToDefaultSpindle(did string) {
+
// use the tangled.sh app password to get an accessJwt
+
// and create an sh.tangled.spindle.member record with that
+
+
defaultSpindle := "spindle.tangled.sh"
+
appPassword := o.config.Core.AppPassword
+
+
// TODO: hardcoded tangled handle and did for now
+
tangledHandle := "tangled.sh"
+
tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli"
+
+
if appPassword == "" {
+
log.Println("no app password configured, skipping spindle member addition")
+
return
+
}
+
+
log.Printf("adding %s to default spindle", did)
+
+
resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid)
+
if err != nil {
+
log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err)
+
return
+
}
+
+
pdsEndpoint := resolved.PDSEndpoint()
+
if pdsEndpoint == "" {
+
log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid)
+
return
+
}
+
+
sessionPayload := map[string]string{
+
"identifier": tangledHandle,
+
"password": appPassword,
+
}
+
sessionBytes, err := json.Marshal(sessionPayload)
+
if err != nil {
+
log.Printf("failed to marshal session payload: %v", err)
+
return
+
}
+
+
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
+
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
+
if err != nil {
+
log.Printf("failed to create session request: %v", err)
+
return
+
}
+
sessionReq.Header.Set("Content-Type", "application/json")
+
+
client := &http.Client{Timeout: 30 * time.Second}
+
sessionResp, err := client.Do(sessionReq)
+
if err != nil {
+
log.Printf("failed to create session: %v", err)
+
return
+
}
+
defer sessionResp.Body.Close()
+
+
if sessionResp.StatusCode != http.StatusOK {
+
log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode)
+
return
+
}
+
+
var session struct {
+
AccessJwt string `json:"accessJwt"`
+
}
+
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
+
log.Printf("failed to decode session response: %v", err)
+
return
+
}
+
+
record := tangled.SpindleMember{
+
LexiconTypeID: "sh.tangled.spindle.member",
+
Subject: did,
+
Instance: defaultSpindle,
+
CreatedAt: time.Now().Format(time.RFC3339),
+
}
+
+
recordBytes, err := json.Marshal(record)
+
if err != nil {
+
log.Printf("failed to marshal spindle member record: %v", err)
+
return
+
}
+
+
payload := map[string]interface{}{
+
"repo": tangledDid,
+
"collection": tangled.SpindleMemberNSID,
+
"rkey": tid.TID(),
+
"record": json.RawMessage(recordBytes),
+
}
+
+
payloadBytes, err := json.Marshal(payload)
+
if err != nil {
+
log.Printf("failed to marshal request payload: %v", err)
+
return
+
}
+
+
url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
+
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
+
if err != nil {
+
log.Printf("failed to create HTTP request: %v", err)
+
return
+
}
+
+
req.Header.Set("Content-Type", "application/json")
+
req.Header.Set("Authorization", "Bearer "+session.AccessJwt)
+
+
resp, err := client.Do(req)
+
if err != nil {
+
log.Printf("failed to add user to default spindle: %v", err)
+
return
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode)
+
return
+
}
+
+
log.Printf("successfully added %s to default spindle", did)
+
}
+
func (o *OAuthHandler) addToDefaultKnot(did string) {
defaultKnot := "knot1.tangled.sh"