···1818// RECORDTYPE: ActorProfile
1919type ActorProfile struct {
2020 LexiconTypeID string `json:"$type,const=sh.tangled.actor.profile" cborgen:"$type,const=sh.tangled.actor.profile"`
2121- // avatar: Small image to be displayed next to posts from account. AKA, 'profile picture'
2222- Avatar *util.LexBlob `json:"avatar,omitempty" cborgen:"avatar,omitempty"`
2321 // bluesky: Include link to this account on Bluesky.
2422 Bluesky bool `json:"bluesky" cborgen:"bluesky"`
2523 // description: Free-form profile description text.
···6262 hx-swap="none"
6363 class="flex flex-col gap-2"
6464>
6565- <label for="email-address" class="uppercase p-0">
6666- add email
6767- </label>
6565+ <p class="uppercase p-0">ADD EMAIL</p>
6866 <p class="text-sm text-gray-500 dark:text-gray-400">Commits using this email will be associated with your profile.</p>
6967 <input
7068 type="email"
···9391 <div id="settings-emails-error" class="text-red-500 dark:text-red-400"></div>
9492 <div id="settings-emails-success" class="text-green-500 dark:text-green-400"></div>
9593</form>
9696-{{ end }}
9494+{{ end }}
+2-4
appview/pages/templates/user/settings/keys.html
···2121 <div class="col-span-1 md:col-span-2">
2222 <h2 class="text-sm pb-2 uppercase font-bold">SSH Keys</h2>
2323 <p class="text-gray-500 dark:text-gray-400">
2424- SSH public keys added here will be broadcasted to knots that you are a member of,
2424+ SSH public keys added here will be broadcasted to knots that you are a member of,
2525 allowing you to push to repositories there.
2626 </p>
2727 </div>
···6363 hx-swap="none"
6464 class="flex flex-col gap-2"
6565>
6666- <label for="key-name" class="uppercase p-0">
6767- add ssh key
6868- </label>
6666+ <p class="uppercase p-0">ADD SSH KEY</p>
6967 <p class="text-sm text-gray-500 dark:text-gray-400">SSH keys allow you to push to repositories in knots you're a member of.</p>
7068 <input
7169 type="text"
+9-6
appview/pages/templates/user/signup.html
···4343 page to complete your registration.
4444 </span>
4545 <div class="w-full mt-4 text-center">
4646- <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
4646+ <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}" data-size="flexible"></div>
4747 </div>
4848 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
4949 <span>join now</span>
5050 </button>
5151+ <p class="text-sm text-gray-500">
5252+ Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
5353+ </p>
5454+5555+ <p id="signup-msg" class="error w-full"></p>
5656+ <p class="text-sm text-gray-500 pt-4">
5757+ By signing up, you agree to our <a href="/terms" class="underline">Terms of Service</a> and <a href="/privacy" class="underline">Privacy Policy</a>.
5858+ </p>
5159 </form>
5252- <p class="text-sm text-gray-500">
5353- Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
5454- </p>
5555-5656- <p id="signup-msg" class="error w-full"></p>
5760 </main>
5861 </body>
5962 </html>
+8
appview/pulls/pulls.go
···13661366 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
13671367 return
13681368 }
13691369+13691370 }
1370137113711372 if err = tx.Commit(); err != nil {
13721373 log.Println("failed to create pull request", err)
13731374 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
13741375 return
13761376+ }
13771377+13781378+ // notify about each pull
13791379+ //
13801380+ // this is performed after tx.Commit, because it could result in a locked DB otherwise
13811381+ for _, p := range stack {
13821382+ s.notifier.NewPull(r.Context(), p)
13751383 }
1376138413771385 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
+17
appview/state/git_http.go
···25252626}
27272828+func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) {
2929+ user, ok := r.Context().Value("resolvedId").(identity.Identity)
3030+ if !ok {
3131+ http.Error(w, "failed to resolve user", http.StatusInternalServerError)
3232+ return
3333+ }
3434+ repo := r.Context().Value("repo").(*models.Repo)
3535+3636+ scheme := "https"
3737+ if s.config.Core.Dev {
3838+ scheme = "http"
3939+ }
4040+4141+ targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
4242+ s.proxyRequest(w, r, targetURL)
4343+}
4444+2845func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) {
2946 user, ok := r.Context().Value("resolvedId").(identity.Identity)
3047 if !ok {
-124
appview/state/profile.go
···737737 AllRepos: allRepos,
738738 })
739739}
740740-741741-func (s *State) UploadProfileAvatar(w http.ResponseWriter, r *http.Request) {
742742- l := s.logger.With("handler", "UploadProfileAvatar")
743743- user := s.oauth.GetUser(r)
744744- l = l.With("did", user.Did)
745745-746746- // Parse multipart form (10MB max)
747747- if err := r.ParseMultipartForm(10 << 20); err != nil {
748748- l.Error("failed to parse form", "err", err)
749749- w.WriteHeader(http.StatusBadRequest)
750750- fmt.Fprintf(w, "Failed to parse form")
751751- return
752752- }
753753-754754- file, handler, err := r.FormFile("avatar")
755755- if err != nil {
756756- l.Error("failed to read avatar file", "err", err)
757757- w.WriteHeader(http.StatusBadRequest)
758758- fmt.Fprintf(w, "Failed to read avatar file")
759759- return
760760- }
761761- defer file.Close()
762762-763763- if handler.Size > 1000000 {
764764- l.Warn("avatar file too large", "size", handler.Size)
765765- w.WriteHeader(http.StatusBadRequest)
766766- fmt.Fprintf(w, "Avatar file too large (max 1MB)")
767767- return
768768- }
769769-770770- contentType := handler.Header.Get("Content-Type")
771771- if contentType != "image/png" && contentType != "image/jpeg" {
772772- l.Warn("invalid image type", "contentType", contentType)
773773- w.WriteHeader(http.StatusBadRequest)
774774- fmt.Fprintf(w, "Invalid image type (only PNG and JPEG allowed)")
775775- return
776776- }
777777-778778- client, err := s.oauth.AuthorizedClient(r)
779779- if err != nil {
780780- l.Error("failed to get PDS client", "err", err)
781781- w.WriteHeader(http.StatusInternalServerError)
782782- fmt.Fprintf(w, "Failed to connect to your PDS")
783783- return
784784- }
785785-786786- uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file)
787787- if err != nil {
788788- l.Error("failed to upload avatar blob", "err", err)
789789- w.WriteHeader(http.StatusInternalServerError)
790790- fmt.Fprintf(w, "Failed to upload avatar to your PDS")
791791- return
792792- }
793793-794794- l.Info("uploaded avatar blob", "cid", uploadBlobResp.Blob.Ref.String())
795795-796796- // get current profile record from PDS to get its CID for swap
797797- getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
798798- if err != nil {
799799- l.Error("failed to get current profile record", "err", err)
800800- w.WriteHeader(http.StatusInternalServerError)
801801- fmt.Fprintf(w, "Failed to get current profile from your PDS")
802802- return
803803- }
804804-805805- var profileRecord *tangled.ActorProfile
806806- if getRecordResp.Value != nil {
807807- if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok {
808808- profileRecord = val
809809- } else {
810810- l.Warn("profile record type assertion failed, creating new record")
811811- profileRecord = &tangled.ActorProfile{}
812812- }
813813- } else {
814814- l.Warn("no existing profile record, creating new record")
815815- profileRecord = &tangled.ActorProfile{}
816816- }
817817-818818- profileRecord.Avatar = uploadBlobResp.Blob
819819-820820- _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
821821- Collection: tangled.ActorProfileNSID,
822822- Repo: user.Did,
823823- Rkey: "self",
824824- Record: &lexutil.LexiconTypeDecoder{Val: profileRecord},
825825- SwapRecord: getRecordResp.Cid,
826826- })
827827-828828- if err != nil {
829829- l.Error("failed to update profile record", "err", err)
830830- w.WriteHeader(http.StatusInternalServerError)
831831- fmt.Fprintf(w, "Failed to update profile on your PDS")
832832- return
833833- }
834834-835835- l.Info("successfully updated profile with avatar")
836836-837837- profile, err := db.GetProfile(s.db, user.Did)
838838- if err != nil {
839839- l.Warn("getting profile data from DB", "err", err)
840840- profile = &models.Profile{Did: user.Did}
841841- }
842842- profile.Avatar = uploadBlobResp.Blob.Ref.String()
843843-844844- tx, err := s.db.BeginTx(r.Context(), nil)
845845- if err != nil {
846846- l.Error("failed to start transaction", "err", err)
847847- s.pages.HxRefresh(w)
848848- w.WriteHeader(http.StatusOK)
849849- return
850850- }
851851-852852- err = db.UpsertProfile(tx, profile)
853853- if err != nil {
854854- l.Error("failed to update profile in DB", "err", err)
855855- tx.Rollback()
856856- s.pages.HxRefresh(w)
857857- w.WriteHeader(http.StatusOK)
858858- return
859859- }
860860-861861- w.Header().Set("HX-Redirect", r.Header.Get("Referer"))
862862- w.WriteHeader(http.StatusOK)
863863-}
+1-1
appview/state/router.go
···101101102102 // These routes get proxied to the knot
103103 r.Get("/info/refs", s.InfoRefs)
104104+ r.Post("/git-upload-archive", s.UploadArchive)
104105 r.Post("/git-upload-pack", s.UploadPack)
105106 r.Post("/git-receive-pack", s.ReceivePack)
106107···162163 r.Get("/edit-pins", s.EditPinsFragment)
163164 r.Post("/bio", s.UpdateProfileBio)
164165 r.Post("/pins", s.UpdateProfilePins)
165165- r.Post("/avatar", s.UploadProfileAvatar)
166166 })
167167168168 r.Mount("/settings", s.SettingsRouter())
···11+package db
22+33+import (
44+ "context"
55+ "database/sql"
66+ "log/slog"
77+ "strings"
88+99+ _ "github.com/mattn/go-sqlite3"
1010+ "tangled.org/core/log"
1111+)
1212+1313+type DB struct {
1414+ db *sql.DB
1515+ logger *slog.Logger
1616+}
1717+1818+func Setup(ctx context.Context, dbPath string) (*DB, error) {
1919+ // https://github.com/mattn/go-sqlite3#connection-string
2020+ opts := []string{
2121+ "_foreign_keys=1",
2222+ "_journal_mode=WAL",
2323+ "_synchronous=NORMAL",
2424+ "_auto_vacuum=incremental",
2525+ }
2626+2727+ logger := log.FromContext(ctx)
2828+ logger = log.SubLogger(logger, "db")
2929+3030+ db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
3131+ if err != nil {
3232+ return nil, err
3333+ }
3434+3535+ conn, err := db.Conn(ctx)
3636+ if err != nil {
3737+ return nil, err
3838+ }
3939+ defer conn.Close()
4040+4141+ _, err = conn.ExecContext(ctx, `
4242+ create table if not exists known_dids (
4343+ did text primary key
4444+ );
4545+4646+ create table if not exists public_keys (
4747+ id integer primary key autoincrement,
4848+ did text not null,
4949+ key text not null,
5050+ created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
5151+ unique(did, key),
5252+ foreign key (did) references known_dids(did) on delete cascade
5353+ );
5454+5555+ create table if not exists _jetstream (
5656+ id integer primary key autoincrement,
5757+ last_time_us integer not null
5858+ );
5959+6060+ create table if not exists events (
6161+ rkey text not null,
6262+ nsid text not null,
6363+ event text not null, -- json
6464+ created integer not null default (strftime('%s', 'now')),
6565+ primary key (rkey, nsid)
6666+ );
6767+6868+ create table if not exists migrations (
6969+ id integer primary key autoincrement,
7070+ name text unique
7171+ );
7272+ `)
7373+ if err != nil {
7474+ return nil, err
7575+ }
7676+7777+ return &DB{
7878+ db: db,
7979+ logger: logger,
8080+ }, nil
8181+}
-64
knotserver/db/init.go
···11-package db
22-33-import (
44- "database/sql"
55- "strings"
66-77- _ "github.com/mattn/go-sqlite3"
88-)
99-1010-type DB struct {
1111- db *sql.DB
1212-}
1313-1414-func Setup(dbPath string) (*DB, error) {
1515- // https://github.com/mattn/go-sqlite3#connection-string
1616- opts := []string{
1717- "_foreign_keys=1",
1818- "_journal_mode=WAL",
1919- "_synchronous=NORMAL",
2020- "_auto_vacuum=incremental",
2121- }
2222-2323- db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
2424- if err != nil {
2525- return nil, err
2626- }
2727-2828- // NOTE: If any other migration is added here, you MUST
2929- // copy the pattern in appview: use a single sql.Conn
3030- // for every migration.
3131-3232- _, err = db.Exec(`
3333- create table if not exists known_dids (
3434- did text primary key
3535- );
3636-3737- create table if not exists public_keys (
3838- id integer primary key autoincrement,
3939- did text not null,
4040- key text not null,
4141- created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
4242- unique(did, key),
4343- foreign key (did) references known_dids(did) on delete cascade
4444- );
4545-4646- create table if not exists _jetstream (
4747- id integer primary key autoincrement,
4848- last_time_us integer not null
4949- );
5050-5151- create table if not exists events (
5252- rkey text not null,
5353- nsid text not null,
5454- event text not null, -- json
5555- created integer not null default (strftime('%s', 'now')),
5656- primary key (rkey, nsid)
5757- );
5858- `)
5959- if err != nil {
6060- return nil, err
6161- }
6262-6363- return &DB{db: db}, nil
6464-}