diff --git a/cmd/borg/borg.go b/cmd/borg/borg.go index 05e491e8..ed62cc7e 100644 --- a/cmd/borg/borg.go +++ b/cmd/borg/borg.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "github.com/kibirisu/borg/internal/config" "github.com/kibirisu/borg/internal/server" @@ -10,6 +11,12 @@ import ( func main() { ctx := context.Background() conf := config.GetConfig() + fmt.Println("--- Config Test ---") + fmt.Printf("Env: %s\n", conf.AppEnv) + fmt.Printf("Host: %s\n", conf.ListenHost) + fmt.Printf("Port: %s\n", conf.ListenPort) + fmt.Printf("DB: %s\n", conf.DatabaseURL) + fmt.Println("-------------------") s := server.New(ctx, conf) panic(s.ListenAndServe()) } diff --git a/internal/server/api.go b/internal/server/api.go index 8636c4aa..2696388e 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -2,13 +2,16 @@ package server import ( "database/sql" + "encoding/json/v2" "errors" "log" "net/http" + "time" "github.com/kibirisu/borg/internal/ap" "github.com/kibirisu/borg/internal/api" "github.com/kibirisu/borg/internal/db" + "github.com/kibirisu/borg/internal/domain" "github.com/kibirisu/borg/internal/server/mapper" "github.com/kibirisu/borg/internal/util" ) @@ -57,8 +60,10 @@ func (s *Server) GetApiAccountsLookup( r *http.Request, params api.GetApiAccountsLookupParams, ) { + // we must check if account is local or from other instance + // if from other instance we do webfinger lookup acct := params.Acct - handle, err := util.ParseHandle(acct, s.conf.ListenHost) + handle, err := util.ParseHandle(acct, s.conf.ListenHost, s.conf.ListenPort) if err != nil { util.WriteError(w, http.StatusBadRequest, err.Error()) return @@ -74,52 +79,95 @@ func (s *Server) GetApiAccountsLookup( } log.Printf("lookup: found local account %s", account.Username) util.WriteJSON(w, http.StatusOK, mapper.AccountToAPI(account)) - return - } + } else { + log.Printf("lookup: remote handle %s detected, checking local cache", acct) + account, err := s.service.App.GetAccount(r.Context(), db.GetAccountParams{ + Username: handle.Username, + Domain: sql.NullString{ + String: handle.Domain, + Valid: true, + }, + }) + if err == nil { + log.Printf("lookup: remote account %s@%s found locally", handle.Username, handle.Domain) + util.WriteJSON(w, http.StatusOK, mapper.AccountToAPI(account)) + return + } + if !errors.Is(err, sql.ErrNoRows) { + log.Println(err) + util.WriteError(w, http.StatusInternalServerError, err.Error()) + return + } - if handle.Domain == "" { - w.WriteHeader(http.StatusBadRequest) - return - } + client := http.Client{Timeout: 5 * time.Second} + actorURL := "http://" + handle.Domain + "/user/" + handle.Username + + reqActor, err := http.NewRequestWithContext(r.Context(), http.MethodGet, actorURL, nil) + if err != nil { + log.Println(err) + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + + log.Printf("lookup: fetching remote actor %s", actorURL) + actorResp, err := client.Do(reqActor) + if err != nil { + log.Println(err) + util.WriteError(w, http.StatusBadGateway, err.Error()) + return + } + defer actorResp.Body.Close() + if actorResp.StatusCode != http.StatusOK { + util.WriteError(w, http.StatusBadGateway, "remote actor fetch failed") + return + } + + var object domain.ObjectOrLink + if err := json.UnmarshalRead(actorResp.Body, &object); err != nil { + log.Println(err) + util.WriteError(w, http.StatusBadGateway, err.Error()) + return + } + actor := ap.NewActor(&object) + actorData := actor.GetObject() + + params := db.CreateActorParams{ + Username: actorData.PreferredUsername, + Uri: actorData.ID, + Domain: sql.NullString{ + String: handle.Domain, + Valid: true, + }, + DisplayName: sql.NullString{}, + InboxUri: actorData.Inbox, + OutboxUri: actorData.Outbox, + FollowersUri: actorData.Followers, + FollowingUri: actorData.Following, + Url: actorData.ID, + } + + log.Printf("lookup: creating remote account %s@%s from actor %s", actorData.PreferredUsername, handle.Domain, actorData.ID) + account, err = s.service.App.AddRemoteAccount(r.Context(), ¶ms) + if err != nil { + log.Println(err) + // If already exists, fetch the stored record. + existing, getErr := s.service.App.GetAccount(r.Context(), db.GetAccountParams{ + Username: actorData.PreferredUsername, + Domain: sql.NullString{ + String: handle.Domain, + Valid: true, + }, + }) + if getErr != nil { + util.WriteError(w, http.StatusInternalServerError, err.Error()) + return + } + util.WriteJSON(w, http.StatusOK, mapper.AccountToAPI(existing)) + return + } - log.Printf("lookup: remote handle %s detected, checking local cache", acct) - account, err := s.service.App.GetAccount( - r.Context(), - db.GetAccountParams{ - Username: handle.Username, - Domain: sql.NullString{String: handle.Domain, Valid: true}, - }, - ) - if err == nil { - log.Printf("lookup: remote account %s@%s found locally", handle.Username, handle.Domain) util.WriteJSON(w, http.StatusOK, mapper.AccountToAPI(account)) - return } - if !errors.Is(err, sql.ErrNoRows) { - log.Println(err) - util.WriteError(w, http.StatusInternalServerError, err.Error()) - return - } - - log.Printf( - "lookup: remote account %s@%s not cached, performing WebFinger lookup", - handle.Username, - handle.Domain, - ) - util.WriteError(w, http.StatusInternalServerError, "unimplemented") - // actor, err := s.service.Federation.processor.LookupActor(r.Context(), handle) - // if err != nil { - // log.Println(err) - // util.WriteError(w, http.StatusBadGateway, err.Error()) - // return - // } - // if err != nil { - // log.Println(err) - // util.WriteError(w, http.StatusInternalServerError, err.Error()) - // return - // } - // log.Printf("lookup: remote actor stored with username=%s domain=%s", row.Username, row.Domain.String) - // util.WriteJSON(w, http.StatusOK, mapper.AccountToAPI(row)) } // PostApiAccountsIdFollow implements api.ServerInterface. diff --git a/internal/service/app.go b/internal/service/app.go index d5c3a393..bd2685b1 100644 --- a/internal/service/app.go +++ b/internal/service/app.go @@ -63,7 +63,7 @@ var _ AppService = (*appService)(nil) // Register implements AppService. func (s *appService) Register(ctx context.Context, form api.AuthForm) error { - uri := fmt.Sprintf("http://%s:%s/user/%s", s.conf.ListenHost, s.conf.ListenPort, form.Username) + uri := fmt.Sprintf("http://%s:%s/user/%s", s.conf.ListenHost, s.conf.ListenPort, form.Username) log.Printf("register: creating actor username=%s uri=%s", form.Username, uri) actor, err := s.store.Accounts().Create(ctx, db.CreateActorParams{ Username: form.Username, @@ -72,9 +72,24 @@ func (s *appService) Register(ctx context.Context, form api.AuthForm) error { Domain: sql.NullString{}, InboxUri: uri + "/inbox", OutboxUri: uri + "/outbox", - Url: fmt.Sprintf("http://%s:%s/profiles/%s", s.conf.ListenHost, s.conf.ListenPort, form.Username), - FollowersUri: fmt.Sprintf("http://%s:%s/user/%s/followers", s.conf.ListenHost, s.conf.ListenPort, form.Username), - FollowingUri: fmt.Sprintf("http://%s:%s/user/%s/following", s.conf.ListenHost, s.conf.ListenPort, form.Username), + Url: fmt.Sprintf( + "http://%s:%s/profiles/%s", + s.conf.ListenHost, + s.conf.ListenPort, + form.Username, + ), + FollowersUri: fmt.Sprintf( + "http://%s:%s/user/%s/followers", + s.conf.ListenHost, + s.conf.ListenPort, + form.Username, + ), + FollowingUri: fmt.Sprintf( + "http://%s:%s/user/%s/following", + s.conf.ListenHost, + s.conf.ListenPort, + form.Username, + ), }) if err != nil { log.Printf("register: failed to create actor username=%s err=%v", form.Username, err) @@ -196,7 +211,12 @@ func (s *appService) CreateFollow( follow *db.CreateFollowParams, ) (*db.Follow, error) { if follow.Uri == "" { - follow.Uri = fmt.Sprintf("http://%s:%s/follows/%s", s.conf.ListenHost, s.conf.ListenPort, uuid.NewString()) + follow.Uri = fmt.Sprintf( + "http://%s:%s/follows/%s", + s.conf.ListenHost, + s.conf.ListenPort, + uuid.NewString(), + ) } return s.store.Follows().Create(ctx, *follow) } @@ -204,7 +224,12 @@ func (s *appService) CreateFollow( // AddNote implements AppService. func (s *appService) AddNote(ctx context.Context, note db.CreateStatusParams) (db.Status, error) { if note.Uri == "" { - note.Uri = fmt.Sprintf("http://%s:%s/statuses/%s", s.conf.ListenHost, s.conf.ListenPort, uuid.NewString()) + note.Uri = fmt.Sprintf( + "http://%s:%s/statuses/%s", + s.conf.ListenHost, + s.conf.ListenPort, + uuid.NewString(), + ) } if note.Url == "" { note.Url = note.Uri @@ -235,7 +260,12 @@ func (s *appService) AddFavourite( params := db.CreateFavouriteParams{ AccountID: int32(accountID), StatusID: int32(postID), - Uri: fmt.Sprintf("http://%s:%s/likes/%s", s.conf.ListenHost, s.conf.ListenPort, uuid.NewString()), + Uri: fmt.Sprintf( + "http://%s:%s/likes/%s", + s.conf.ListenHost, + s.conf.ListenPort, + uuid.NewString(), + ), } return s.store.Favourites().Create(ctx, params) } @@ -290,7 +320,12 @@ func (s *appService) FollowAccount( followee int, ) (*db.Follow, error) { createParams := db.CreateFollowParams{ - Uri: fmt.Sprintf("http://%s:%s/follows/%s", s.conf.ListenHost, s.conf.ListenPort, uuid.NewString()), + Uri: fmt.Sprintf( + "http://%s:%s/follows/%s", + s.conf.ListenHost, + s.conf.ListenPort, + uuid.NewString(), + ), AccountID: int32(follower), TargetAccountID: int32(followee), } @@ -345,4 +380,4 @@ func (s *appService) GetTimelinePostsByAccountId( accountID int, ) ([]db.GetTimelinePostsByAccountIdRow, error) { return s.store.Statuses().GetTimelinePostsByAccountId(ctx, accountID) -} \ No newline at end of file +} diff --git a/internal/util/handle.go b/internal/util/handle.go index cb673ff0..83580016 100644 --- a/internal/util/handle.go +++ b/internal/util/handle.go @@ -12,12 +12,14 @@ type HandleInfo struct { Local bool // true if domain matches local host } -func ParseHandle(raw string, localHost string) (*HandleInfo, error) { +func ParseHandle(raw string, localHost string, localPort string) (*HandleInfo, error) { handle := strings.TrimSpace(raw) if handle == "" { return nil, errors.New("empty handle") } + // Accept "@user@host" with optional leading "@". handle = strings.TrimPrefix(handle, "@") + parts := strings.Split(handle, "@") if len(parts) != 2 { return nil, fmt.Errorf("invalid handle format: %s", raw) @@ -27,10 +29,14 @@ func ParseHandle(raw string, localHost string) (*HandleInfo, error) { if username == "" || domain == "" { return nil, fmt.Errorf("invalid handle format: %s", raw) } + localDomain := localHost + if localPort != "" { + localDomain = localHost + ":" + localPort + } info := &HandleInfo{ Username: username, Domain: domain, - Local: strings.EqualFold(domain, localHost), + Local: strings.EqualFold(domain, localHost) || strings.EqualFold(domain, localDomain), } return info, nil } diff --git a/web/src/components/common/PostItem.tsx b/web/src/components/common/PostItem.tsx index cd946a08..706f59ef 100644 --- a/web/src/components/common/PostItem.tsx +++ b/web/src/components/common/PostItem.tsx @@ -1,7 +1,7 @@ import { Heart, MessageCircle, Repeat, Share2 } from "lucide-react"; import { useContext } from "react"; import ReactMarkdown from "react-markdown"; -import { Link } from "react-router"; +import { Link, useNavigate } from "react-router"; import type { components } from "../../lib/api/v1"; import type { AppClient } from "../../lib/client"; import AppContext from "../../lib/state"; @@ -32,6 +32,7 @@ export const PostItem = ({ }: PostProps) => { const appState = useContext(AppContext); const currentUserId = appState?.userId ?? null; + const navigate = useNavigate(); const likeAction = async () => { if (!("id" in post.data)) return; @@ -149,6 +150,10 @@ export const PostItem = ({ event.stopPropagation(); if (onCommentClick) { onCommentClick(post); + return; + } + if ("id" in post.data) { + navigate(`/post/${post.data.id}`); } }} > diff --git a/web/src/components/common/Sidebar.tsx b/web/src/components/common/Sidebar.tsx index df6d1fc9..a1124c1b 100644 --- a/web/src/components/common/Sidebar.tsx +++ b/web/src/components/common/Sidebar.tsx @@ -230,7 +230,8 @@ export default function Sidebar({ onPostClick }: SidebarProps) { ); diff --git a/web/src/components/feed/CommentView.tsx b/web/src/components/feed/CommentView.tsx index 1163154a..b715acae 100644 --- a/web/src/components/feed/CommentView.tsx +++ b/web/src/components/feed/CommentView.tsx @@ -73,24 +73,22 @@ export default function CommentView() { return null; } return ( -
-
-
- {postData && postData.data ? ( - - ) : ( -
Post not found.
- )} -
-
- -
-
- -
+
+
+ {postData && postData.data ? ( + + ) : ( +
Post not found.
+ )} +
+
+ +
+
+
); diff --git a/web/src/components/pages/ExplorePage.tsx b/web/src/components/pages/ExplorePage.tsx index 6dce50f4..af4a5a88 100644 --- a/web/src/components/pages/ExplorePage.tsx +++ b/web/src/components/pages/ExplorePage.tsx @@ -4,10 +4,40 @@ import { useLoaderData, useNavigate } from "react-router"; import type { components } from "../../lib/api/v1"; import type { AppClient } from "../../lib/client"; import ClientContext from "../../lib/client"; +import AppContext from "../../lib/state"; import PostComposerOverlay from "../common/PostComposerOverlay"; import { PostItem, type PostPresentable } from "../common/PostItem"; import Sidebar from "../common/Sidebar"; +function FoundUserItem({ + account, +}: { + account: components["schemas"]["Account"]; +}) { + const display = account.displayName || account.username; + const handle = account.acct || `@${account.username}`; + const initial = display?.slice(0, 1).toUpperCase() || "?"; + + return ( +
+
+
+ {initial} +
+
+ + {display} + + {handle} +
+
+
+ ); +} + export const loader = (client: AppClient) => async () => { const opts = client.$api.queryOptions("get", "/api/posts", {}); await client.queryClient.ensureQueryData(opts); @@ -16,6 +46,7 @@ export const loader = (client: AppClient) => async () => { export default function ExplorePage() { const client = useContext(ClientContext); + const appState = useContext(AppContext); const navigate = useNavigate(); const { opts } = useLoaderData() as Awaited< ReturnType> @@ -56,9 +87,9 @@ export default function ExplorePage() { const handleSearch = async (event: React.FormEvent) => { event.preventDefault(); const trimmed = searchTerm.trim(); - const handlePattern = /^@[A-Za-z0-9._-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/; + const handlePattern = /^@[A-Za-z0-9._-]+@[A-Za-z0-9.-]+(?::\d{2,5})?$/; if (!handlePattern.test(trimmed)) { - setSearchError("Format must be @user@instance.com"); + setSearchError("Format must be @user@host or @user@host:port"); return; } if (!client) { @@ -97,6 +128,22 @@ export default function ExplorePage() { setSelectedPost(null); }; + const handleCreatePost = async (content: string) => { + const userId = appState?.userId ?? null; + if (!client || userId === null) { + throw new Error("User not authenticated"); + } + await client.fetchClient.POST("/api/posts", { + body: { userID: userId, content }, + }); + await client.queryClient.invalidateQueries({ + queryKey: ["user-posts", userId], + }); + await client.queryClient.invalidateQueries({ + queryKey: ["get", "/api/posts", {}], + }); + }; + const onSearchChange = (event: React.ChangeEvent) => { setSearchTerm(event.target.value); if (searchError) { @@ -143,7 +190,7 @@ export default function ExplorePage() { {searchError && ( -

{searchError}

+
+ +

+ Warning! + {searchError === "Error during fetching client." + ? "Sorry, we coudn't find this profile. Are you sure you entered the username and host correctly?" + : searchError} +

+
)} {searchResult && ( -
-

Search result

-

- {searchResult.displayName || searchResult.username} -

-

- {searchResult.acct || `@${searchResult.username}`} +

)}
@@ -241,6 +283,7 @@ export default function ExplorePage() { isOpen={isComposerOpen} onClose={closeComposer} replyTo={selectedPost} + onSubmit={handleCreatePost} />
); diff --git a/web/src/components/pages/LikesPage.tsx b/web/src/components/pages/LikesPage.tsx index 89bc5225..819b1b7c 100644 --- a/web/src/components/pages/LikesPage.tsx +++ b/web/src/components/pages/LikesPage.tsx @@ -2,6 +2,7 @@ import { useContext, useState } from "react"; import { useLoaderData } from "react-router"; import type { AppClient } from "../../lib/client"; import ClientContext from "../../lib/client"; +import AppContext from "../../lib/state"; import PostComposerOverlay from "../common/PostComposerOverlay"; import Sidebar from "../common/Sidebar"; @@ -10,7 +11,8 @@ export const loader = (_client: AppClient) => async () => { }; export default function LikesPage() { - const _client = useContext(ClientContext); + const client = useContext(ClientContext); + const appState = useContext(AppContext); useLoaderData(); const [isComposerOpen, setIsComposerOpen] = useState(false); const [selectedPost, setSelectedPost] = useState(null); @@ -25,6 +27,22 @@ export default function LikesPage() { setSelectedPost(null); }; + const handleCreatePost = async (content: string) => { + const userId = appState?.userId ?? null; + if (!client || userId === null) { + throw new Error("User not authenticated"); + } + await client.fetchClient.POST("/api/posts", { + body: { userID: userId, content }, + }); + await client.queryClient.invalidateQueries({ + queryKey: ["user-posts", userId], + }); + await client.queryClient.invalidateQueries({ + queryKey: ["get", "/api/posts", {}], + }); + }; + return (
@@ -44,6 +62,7 @@ export default function LikesPage() { isOpen={isComposerOpen} onClose={closeComposer} replyTo={selectedPost} + onSubmit={handleCreatePost} />
); diff --git a/web/src/components/pages/OtherUserPage.tsx b/web/src/components/pages/OtherUserPage.tsx index 2d73e418..1b4088de 100644 --- a/web/src/components/pages/OtherUserPage.tsx +++ b/web/src/components/pages/OtherUserPage.tsx @@ -6,6 +6,7 @@ import type { components } from "../../lib/api/v1"; import type { AppClient } from "../../lib/client"; import ClientContext from "../../lib/client"; import AppContext from "../../lib/state"; +import PostComposerOverlay from "../common/PostComposerOverlay"; import { PostItem } from "../common/PostItem"; import Sidebar from "../common/Sidebar"; @@ -41,6 +42,7 @@ export default function OtherUserPage() { const [isFollowed, setIsFollowed] = useState(false); const [followError, setFollowError] = useState(null); const [followPending, setFollowPending] = useState(false); + const [isComposerOpen, setIsComposerOpen] = useState(false); const { data: profileData } = useQuery({ queryKey: ["profile", handle ?? derivedUsername], @@ -155,6 +157,27 @@ export default function OtherUserPage() { setIsFollowed(followers.some((follower) => follower.id === tokenUserId)); }, [followers, tokenUserId]); + const openComposerForNewPost = () => { + setIsComposerOpen(true); + }; + + const closeComposer = () => setIsComposerOpen(false); + + const handleCreatePost = async (content: string) => { + if (!client || tokenUserId === null) { + throw new Error("User not authenticated"); + } + await client.fetchClient.POST("/api/posts", { + body: { userID: tokenUserId, content }, + }); + await client.queryClient.invalidateQueries({ + queryKey: ["user-posts", tokenUserId], + }); + await client.queryClient.invalidateQueries({ + queryKey: ["get", "/api/posts", {}], + }); + }; + return (
@@ -233,8 +256,14 @@ export default function OtherUserPage() { )} - +
+
); } diff --git a/web/src/components/pages/SharedPage.tsx b/web/src/components/pages/SharedPage.tsx index 6c4d09c8..9b52103e 100644 --- a/web/src/components/pages/SharedPage.tsx +++ b/web/src/components/pages/SharedPage.tsx @@ -4,6 +4,7 @@ import { useLoaderData } from "react-router"; import type { components } from "../../lib/api/v1"; import type { AppClient } from "../../lib/client"; import ClientContext from "../../lib/client"; +import AppContext from "../../lib/state"; import PostComposerOverlay from "../common/PostComposerOverlay"; import { PostItem, type PostPresentable } from "../common/PostItem"; import Sidebar from "../common/Sidebar"; @@ -17,6 +18,7 @@ export const loader = (client: AppClient) => async () => { export default function SharedPage() { const client = useContext(ClientContext); + const appState = useContext(AppContext); const { opts } = useLoaderData() as Awaited< ReturnType> >; @@ -52,6 +54,22 @@ export default function SharedPage() { setSelectedPost(null); }; + const handleCreatePost = async (content: string) => { + const userId = appState?.userId ?? null; + if (!client || userId === null) { + throw new Error("User not authenticated"); + } + await client.fetchClient.POST("/api/posts", { + body: { userID: userId, content }, + }); + await client.queryClient.invalidateQueries({ + queryKey: ["user-posts", userId], + }); + await client.queryClient.invalidateQueries({ + queryKey: ["get", "/api/posts", {}], + }); + }; + return (
@@ -97,6 +115,7 @@ export default function SharedPage() { isOpen={isComposerOpen} onClose={closeComposer} replyTo={selectedPost} + onSubmit={handleCreatePost} />
); diff --git a/web/src/components/pages/UserPage.tsx b/web/src/components/pages/UserPage.tsx index 2f37b0e7..29a0f9ae 100644 --- a/web/src/components/pages/UserPage.tsx +++ b/web/src/components/pages/UserPage.tsx @@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query"; import { useContext, useMemo, useState } from "react"; import { type LoaderFunctionArgs, - Outlet, useLoaderData, useNavigate, } from "react-router"; @@ -216,7 +215,6 @@ export default function UserPage() { )} )} -