aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--Dockerfile27
-rw-r--r--air.toml23
-rw-r--r--compose.yml41
-rw-r--r--env.example18
-rw-r--r--fetchers/app.bsky.feed.go8
-rw-r--r--fetchers/config.go1
-rw-r--r--frontend/public/image-unavailable.svg1
-rw-r--r--frontend/src/lightbox.ts82
-rw-r--r--frontend/src/main.ts31
-rw-r--r--frontend/src/style.css137
-rw-r--r--frontend/vite.config.ts5
-rw-r--r--main.go30
-rw-r--r--notes/app.bsky.feed.go12
-rw-r--r--notes/author.go8
-rw-r--r--notes/note.go2
16 files changed, 417 insertions, 12 deletions
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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM11 15V17H13V15H11ZM11 7V13H13V7H11Z"></path></svg> \ 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<Note['media']>, 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{},
}
}