From 10e54b1bb7979dc21911afce0d20cffc4a2d038a Mon Sep 17 00:00:00 2001 From: Guillermo Serrahima Date: Tue, 2 Jun 2026 03:15:03 +0200 Subject: added images --- .gitignore | 3 +- Dockerfile | 27 +++++++ air.toml | 23 ++++++ compose.yml | 41 ++++++++++ env.example | 18 +++++ fetchers/app.bsky.feed.go | 8 +- fetchers/config.go | 1 + frontend/public/image-unavailable.svg | 1 + frontend/src/lightbox.ts | 82 ++++++++++++++++++++ frontend/src/main.ts | 31 +++++++- frontend/src/style.css | 137 +++++++++++++++++++++++++++++++++- frontend/vite.config.ts | 5 ++ main.go | 30 ++++++++ notes/app.bsky.feed.go | 12 +-- notes/author.go | 8 ++ notes/note.go | 2 +- 16 files changed, 417 insertions(+), 12 deletions(-) create mode 100644 Dockerfile create mode 100644 air.toml create mode 100644 compose.yml create mode 100644 env.example create mode 100644 frontend/public/image-unavailable.svg create mode 100644 frontend/src/lightbox.ts diff --git a/.gitignore b/.gitignore index 03ef822..751843a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ -.vscode +.vscode/ .env README.md +tmp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ce9b164 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# stage 1: build frontend +FROM node:22-alpine AS frontend +WORKDIR /app/frontend +COPY frontend/package.json frontend/bun.lockb* ./ +RUN npm install -g bun && bun install +COPY frontend/ ./ +RUN bun run build + +# stage 2: build backend +FROM golang:1.25-alpine AS backend +WORKDIR /app +COPY go.mod go.sum main.go ./ +COPY auth/ ./auth/ +COPY fetchers/ ./fetchers/ +COPY general/ ./general/ +COPY notes/ ./notes/ +COPY --from=frontend /app/frontend/dist ./frontend/dist +RUN go mod download +RUN go build -o llostre . + +# stage 3: runtime +FROM alpine:latest +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=backend /app/llostre . +EXPOSE 8080 +CMD ["./llostre"] diff --git a/air.toml b/air.toml new file mode 100644 index 0000000..198cea2 --- /dev/null +++ b/air.toml @@ -0,0 +1,23 @@ +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -o ./tmp/llostre ." +bin = "./tmp/llostre" +delay = 1000 +exclude_dir = ["tmp", "../frontend"] +include_ext = ["go"] +kill_delay = "0s" +rerun_delay = 500 + +[log] +time = false + +[color] +main = "magenta" +watcher = "cyan" +build = "yellow" +runner = "green" + +[misc] +clean_on_exit = true diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..4b2ddd4 --- /dev/null +++ b/compose.yml @@ -0,0 +1,41 @@ +services: + llostre: + image: llostre + restart: unless-stopped + ports: + - "8080:8080" + env_file: .env + depends_on: + redis: + condition: service_healthy + porxie: + condition: service_started + + redis: + image: redis:7-alpine + restart: unless-stopped + command: > + redis-server + ${REDIS_PASSWORD:+--requirepass ${REDIS_PASSWORD}} + ports: + - "6379:6379" + #volumes: + # - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + porxie: + image: blooym/porxie:latest + restart: unless-stopped + environment: + PORXIE_SERVER_ADDRESS: "ip:0.0.0.0:6314" + PORXIE_BLOB_ALLOWED_MIMETYPES: "image/*,video/*" + ports: + - "6314:6314" + +#volumes: +# redis_data: diff --git a/env.example b/env.example new file mode 100644 index 0000000..d55b67c --- /dev/null +++ b/env.example @@ -0,0 +1,18 @@ +# your app's public domain, no trailing slash +APP_DOMAIN=https://llostre.example.com + +# log level: debug, info, warn, error +LOG_LEVEL=info + +# slingshot public cache: leave empty to disable +SLINGSHOT_URL=https://slingshot.microcosm.blue + +# redis connection +REDIS_ADDR=redis:6379 +REDIS_PASSWORD= + +# cache TTL in minutes +TTL_MIN=15 + +# porxie blob proxy +PORXIE_ADDR=porxie:6314 diff --git a/fetchers/app.bsky.feed.go b/fetchers/app.bsky.feed.go index 5eebec9..1083256 100644 --- a/fetchers/app.bsky.feed.go +++ b/fetchers/app.bsky.feed.go @@ -21,7 +21,7 @@ func FetchAppBskyFeedPosts(conf *Config, did, pdsURL string, session *auth.OAuth } // try slingshot first if given - if conf.SlingshotURL != "" && general.SlingshotAvailable() { + if notes.IsPublic(author) && conf.SlingshotURL != "" && general.SlingshotAvailable() { records, err := general.FetchRawRecordCollection(conf.Logger, did, "app.bsky.feed.post", conf.SlingshotURL, session) if err != nil { conf.Logger.Error("fetching from slingshot failed, falling back to PDS") @@ -143,7 +143,7 @@ func parseAppBskyFeedPostRecord(rawRecord *general.RawRecord, author *notes.Auth return nil, err } - note, err := notes.NoteFromAppBskyFeedPostRecord(record, author) + note, err := notes.NoteFromAppBskyFeedPostRecord(record, author, conf.PorxieURL) if err != nil { return nil, err } @@ -151,7 +151,9 @@ func parseAppBskyFeedPostRecord(rawRecord *general.RawRecord, author *notes.Auth // cache it conf.Cache.Set(note.URI, note) - conf.Logger.Debug("parsed note", "uri", note.URI, "media_count", len(note.Media), "embed", record.Value.Embed) + if len(note.Media) > 0 { + conf.Logger.Debug("parsed note", "uri", note.URI, "media_count", len(note.Media), "embed", record.Value.Embed) + } return note, nil } diff --git a/fetchers/config.go b/fetchers/config.go index 090eb09..a8aa711 100644 --- a/fetchers/config.go +++ b/fetchers/config.go @@ -4,6 +4,7 @@ import "log/slog" type Config struct { SlingshotURL string + PorxieURL string PageSize int Logger *slog.Logger Cache *Cache diff --git a/frontend/public/image-unavailable.svg b/frontend/public/image-unavailable.svg new file mode 100644 index 0000000..7aa1741 --- /dev/null +++ b/frontend/public/image-unavailable.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/lightbox.ts b/frontend/src/lightbox.ts new file mode 100644 index 0000000..cdc628e --- /dev/null +++ b/frontend/src/lightbox.ts @@ -0,0 +1,82 @@ +import type { Note } from './main'; + +let currentMedia: Note['media'] = []; +let currentIndex = 0; + +function updateLightbox() { + const item = currentMedia![currentIndex]; + const img = document.getElementById('lightbox-img') as HTMLImageElement; + const alt = document.getElementById('lightbox-alt')!; + const prev = document.getElementById('lightbox-prev')!; + const next = document.getElementById('lightbox-next')!; + + img.src = item.url; + img.alt = item.alt || ''; + + if (item.alt) { + alt.textContent = item.alt; + alt.style.display = 'block'; + } else { + alt.style.display = 'none'; + } + + prev.style.display = currentIndex > 0 ? 'block' : 'none'; + next.style.display = currentIndex < currentMedia!.length - 1 ? 'block' : 'none'; +} + +function navigateLightbox(dir: number) { + const next = currentIndex + dir; + if (next < 0 || next >= currentMedia!.length) return; + currentIndex = next; + updateLightbox(); +} + +function lightboxKeyHandler(e: KeyboardEvent) { + if (e.key === 'Escape') closeLightbox(); + if (e.key === 'ArrowLeft') navigateLightbox(-1); + if (e.key === 'ArrowRight') navigateLightbox(1); +} + +export function openLightbox(media: NonNullable, index: number) { + currentMedia = media; + currentIndex = index; + + const overlay = document.createElement('div'); + overlay.id = 'lightbox'; + overlay.addEventListener('click', e => { + if (e.target === overlay) closeLightbox(); + }); + + const close = document.createElement('button'); + close.id = 'lightbox-close'; + close.textContent = '×'; + close.addEventListener('click', closeLightbox); + overlay.appendChild(close); + + const img = document.createElement('img'); + img.id = 'lightbox-img'; + + const alt = document.createElement('div'); + alt.id = 'lightbox-alt'; + + const prev = document.createElement('button'); + prev.id = 'lightbox-prev'; + prev.textContent = '‹'; + prev.addEventListener('click', () => navigateLightbox(-1)); + + const next = document.createElement('button'); + next.id = 'lightbox-next'; + next.textContent = '›'; + next.addEventListener('click', () => navigateLightbox(1)); + + overlay.append(prev, img, alt, next); + document.body.appendChild(overlay); + updateLightbox(); + + document.addEventListener('keydown', lightboxKeyHandler); +} + +export function closeLightbox() { + document.getElementById('lightbox')?.remove(); + document.removeEventListener('keydown', lightboxKeyHandler); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 056e9f0..8f43d25 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,7 +1,8 @@ import { marked } from 'marked'; import './style.css' +import { openLightbox } from './lightbox'; -interface Note { +export interface Note { uri: string; authorDID: string; authorHandle: string; @@ -174,11 +175,39 @@ function createNoteElement(note: Note): HTMLElement { note.media.forEach(item => { const img = document.createElement('img'); img.src = item.url; + img.src = item.url; img.alt = item.alt || ''; + img.className = item.alt ? 'has-alt' : 'no-alt'; if (item.width && item.height) { img.width = item.width; img.height = item.height; } + img.style.cursor = 'pointer'; + const index = note.media!.indexOf(item); + img.addEventListener('click', () => openLightbox(note.media!, index)); + mediaDiv.appendChild(img); + img.onerror = () => { + const errDiv = document.createElement('div'); + errDiv.className = 'note-media-error'; + + const icon = document.createElement('img'); + icon.src = '/image-unavailable.svg'; + icon.alt = ''; + errDiv.appendChild(icon); + + const label = document.createElement('div'); + label.className = 'info'; + label.textContent = 'Image unavailable'; + errDiv.appendChild(label); + + if (item.alt) { + const altText = document.createElement('div'); + altText.textContent = item.alt; + errDiv.appendChild(altText); + } + + img.replaceWith(errDiv); + }; mediaDiv.appendChild(img); }); content.appendChild(mediaDiv); diff --git a/frontend/src/style.css b/frontend/src/style.css index 8e1d095..50836d8 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -161,6 +161,141 @@ h1 { white-space: pre-wrap; } +.note-media { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(45%, 1fr)); + grid-auto-rows: 200px; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.note-media:has(> :only-child) { + grid-auto-rows: auto; + justify-content: start; +} + +.note-media:has(> :only-child) img { + width: auto; + max-width: 100%; + height: auto; + max-height: 400px; + object-fit: contain; +} + +.note-media img, +.note-media-error { + width: 100%; + height: 100%; + box-sizing: border-box; + overflow: hidden; + border-radius: 8px; +} + +.note-media img { + object-fit: cover; +} + +.note-media img.has-alt { + border: 2px solid var(--accent); +} + +.note-media img.no-alt { + border: 2px solid var(--error); +} + +.note-media-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: 1rem; + background: var(--bg-secondary); + border: 2px solid var(--error); + text-align: center; + font-size: 0.85rem; +} + +.note-media-error img { + width: 48px; + height: 48px; + border: none !important; + object-fit: contain; + height: auto; + max-height: unset; + grid-auto-rows: unset; +} + +#lightbox { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + flex-direction: column; + gap: 1rem; + padding: 2rem; +} + +#lightbox-img { + max-width: 90vw; + max-height: 80vh; + object-fit: contain; + border-radius: 8px; + border: none !important; +} + +#lightbox-alt { + color: white; + background: rgba(0, 0, 0, 0.6); + padding: 0.5rem 1rem; + border-radius: 8px; + max-width: 60ch; + text-align: center; + font-size: 0.9rem; +} + +#lightbox-prev, +#lightbox-next { + position: fixed; + top: 50%; + transform: translateY(-50%); + background: rgba(0, 0, 0, 0.5); + color: white; + border: none; + font-size: 2rem; + padding: 0.5rem 1rem; + cursor: pointer; + border-radius: 4px; +} + +#lightbox-prev { left: 1rem; } +#lightbox-next { right: 1rem; } + +#lightbox-prev:hover, +#lightbox-next:hover { + background: rgba(0, 0, 0, 0.8); +} + +#lightbox-close { + position: fixed; + top: 1rem; + right: 1rem; + background: rgba(0, 0, 0, 0.5); + color: white; + border: none; + font-size: 1.5rem; + width: 2rem; + height: 2rem; + cursor: pointer; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + .muted { color: var(--text-muted); font-size: 12px; @@ -173,7 +308,7 @@ h1 { } .spinner { - border: 8px solid #f3f3f3; + border: 8px solid var(--border); border-top: 8px solid var(--accent); border-radius: 50%; width: 64px; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4420756..e8228d4 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,4 +10,9 @@ export default defineConfig({ }, }, }, + server: { + proxy: { + '/api': 'http://localhost:8080', + }, + }, }) diff --git a/main.go b/main.go index 6907fa4..0ed7c67 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "embed" "encoding/json" "fmt" + "io" "io/fs" "llostre/auth" "llostre/fetchers" @@ -82,8 +83,14 @@ func init() { panic(err) } + porxieAddr := os.Getenv("PORXIE_URL") + if porxieAddr == "" { + porxieAddr = "porxie:6314" + } + config = &fetchers.Config{ SlingshotURL: slingshotURL, + PorxieURL: fmt.Sprintf("http://%s", os.Getenv("PORXIE_ADDR")), PageSize: 50, Logger: logger, Cache: postCache, @@ -417,6 +424,28 @@ func postHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(note) } +func blobHandler(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/blob/") + parts := strings.SplitN(path, "/", 2) + if len(parts) != 2 { + http.Error(w, "invalid blob path", http.StatusBadRequest) + return + } + + porxieResp, err := http.Get(fmt.Sprintf("%s/%s/%s", config.PorxieURL, parts[0], parts[1])) + if err != nil { + config.Logger.Error("blob fetch failed", "url", porxieResp, "error", err) + http.Error(w, "blob fetch failed", http.StatusBadGateway) + return + } + defer porxieResp.Body.Close() + + w.Header().Set("Content-Type", porxieResp.Header.Get("Content-Type")) + w.Header().Set("Cache-Control", porxieResp.Header.Get("Cache-Control")) + w.WriteHeader(porxieResp.StatusCode) + io.Copy(w, porxieResp.Body) +} + func main() { defer config.Cache.Close() @@ -437,5 +466,6 @@ func main() { http.HandleFunc("/api/login", loginHandler) http.HandleFunc("/api/callback", callbackHandler) http.HandleFunc("/api/post/", postHandler) // /api/post/{uri} + http.HandleFunc("/api/blob/", blobHandler) http.ListenAndServe("0.0.0.0:8080", nil) } diff --git a/notes/app.bsky.feed.go b/notes/app.bsky.feed.go index 21b94f9..1ee3cca 100644 --- a/notes/app.bsky.feed.go +++ b/notes/app.bsky.feed.go @@ -9,7 +9,9 @@ import ( type EmbedImage struct { Alt string `json:"alt"` Image struct { - CID string `json:"$link"` + Ref struct { + CID string `json:"$link"` + } `json:"ref"` } `json:"image"` AspectRatio *struct { Width int `json:"width"` @@ -54,7 +56,7 @@ type AppBskyFeedPostRecord struct { } `json:"value"` } -func extractImages(embed *AppBskyFeedPostEmbed, did, pdsURL string) []Media { +func extractImages(embed *AppBskyFeedPostEmbed, did string) []Media { if embed == nil { return nil } @@ -65,7 +67,7 @@ func extractImages(embed *AppBskyFeedPostEmbed, did, pdsURL string) []Media { var media []Media for _, img := range images { m := Media{ - URL: fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", pdsURL, did, img.Image.CID), + URL: fmt.Sprintf("/api/blob/%s/%s", did, img.Image.Ref.CID), Alt: img.Alt, } if img.AspectRatio != nil { @@ -77,7 +79,7 @@ func extractImages(embed *AppBskyFeedPostEmbed, did, pdsURL string) []Media { return media } -func NoteFromAppBskyFeedPostRecord(record AppBskyFeedPostRecord, author *Author) (*Note, error) { +func NoteFromAppBskyFeedPostRecord(record AppBskyFeedPostRecord, author *Author, porxieURL string) (*Note, error) { if record.URI == "" { return nil, fmt.Errorf("missing required field: uri") } @@ -145,6 +147,6 @@ func NoteFromAppBskyFeedPostRecord(record AppBskyFeedPostRecord, author *Author) Text: record.Value.Text, ReplyTo: replyTo, Facets: facets, - Media: extractImages(record.Value.Embed, did, author.PDS), + Media: extractImages(record.Value.Embed, did), }, nil } diff --git a/notes/author.go b/notes/author.go index faf4f7b..74201c1 100644 --- a/notes/author.go +++ b/notes/author.go @@ -9,3 +9,11 @@ type Author struct { Pronouns string NoUnauth bool } + +func IsPublic(author *Author) bool { + if author == nil { + return false + } + + return !author.NoUnauth +} diff --git a/notes/note.go b/notes/note.go index 15f6f67..f6b8bf3 100644 --- a/notes/note.go +++ b/notes/note.go @@ -37,7 +37,7 @@ func MissingNote(uri string) *Note { return &Note{ URI: uri, AuthorDID: "MISSING", - Text: "This post could not be fetched. It may have been deleted.", + Text: "This post is unavailable. It may have been deleted.", CreatedAt: time.Time{}, } } -- cgit v1.2.3