Skip to content

Commit 8f12cf4

Browse files
committed
release: prep v0.1.5 (proxy routing + upload gate)
1 parent 2ab3260 commit 8f12cf4

File tree

11 files changed

+389
-31
lines changed

11 files changed

+389
-31
lines changed

.env.dev.example

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ DEV_PDF_PROXY_PORT=8280
1414
DEV_WEB_LIBRARY_PORT=8281
1515

1616
# Base URL that Web Library should use for opening PDFs via the proxy.
17-
# In local dev this is typically http://localhost:8280/pdf
18-
DEV_PDF_PROXY_BASE_URL=http://localhost:8280/pdf
17+
# Set to a host reachable by your browser (not localhost if accessed via NAS).
18+
DEV_PDF_PROXY_BASE_URL=http://192.168.0.50:8280/pdf
19+
20+
# On-prem upload gating (set false to block WebUI uploads to Zotero Storage; sync via Desktop/WebDAV instead)
21+
WEB_LIBRARY_ALLOW_UPLOADS=false
1922

2023
# Metadata source for Web Library (runtime templating)
2124
# Defaults target zotero.org; set to your account to mirror zotero.org metadata.

.env.portainer.example

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Portainer stack environment (example values). Copy to `.env.portainer` for Portainer UI deployment.
22

33
# GHCR images to deploy (choose tags built by CI) — default to a pinned release tag
4-
PDF_PROXY_IMAGE=ghcr.io/joonsoome/on-prem-zotero-webui/pdf-proxy:v0.1.1
5-
WEB_LIBRARY_IMAGE=ghcr.io/joonsoome/on-prem-zotero-webui/web-library:v0.1.1
4+
PDF_PROXY_IMAGE=ghcr.io/joonsoome/on-prem-zotero-webui/pdf-proxy:v0.1.5
5+
WEB_LIBRARY_IMAGE=ghcr.io/joonsoome/on-prem-zotero-webui/web-library:v0.1.5
66

77
# Host path for Zotero WebDAV data mounted into the proxy container
88
ZOTERO_ROOT_HOST_PATH=/volume1/Reference/zotero
@@ -12,7 +12,7 @@ PDF_PROXY_PORT=8280
1212
WEB_LIBRARY_PORT=8281
1313

1414
# Base URL that the Web Library should use when linking to the PDF proxy (update to your staging hostname/IP)
15-
PDF_PROXY_BASE_URL=http://nas.local:8280/pdf
15+
PDF_PROXY_BASE_URL=http://192.168.0.50:8280/pdf
1616

1717
# Metadata source (runtime templating for the Web Library)
1818
# If you host metadata on zotero.org (default):
@@ -31,3 +31,6 @@ ZOTERO_INCLUDE_MY_LIBRARY=true
3131
ZOTERO_INCLUDE_USER_GROUPS=true
3232
# JSON array of explicit libraries/groups to include (e.g., [{"key":"g123","name":"Team","isGroupLibrary":true}])
3333
ZOTERO_LIBRARIES_INCLUDE_JSON=[]
34+
35+
# On-prem upload gating (false = block WebUI uploads to Zotero Storage; use Desktop/WebDAV sync)
36+
WEB_LIBRARY_ALLOW_UPLOADS=false

.env.stage.example

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Staging/NAS environment configuration (example values). Copy to `.env.stage` on the staging host.
22

33
# GHCR images to deploy (choose tags built by CI) — default to a pinned release tag
4-
PDF_PROXY_IMAGE=ghcr.io/joonsoome/on-prem-zotero-webui/pdf-proxy:v0.1.1
5-
WEB_LIBRARY_IMAGE=ghcr.io/joonsoome/on-prem-zotero-webui/web-library:v0.1.1
4+
PDF_PROXY_IMAGE=ghcr.io/joonsoome/on-prem-zotero-webui/pdf-proxy:v0.1.5
5+
WEB_LIBRARY_IMAGE=ghcr.io/joonsoome/on-prem-zotero-webui/web-library:v0.1.5
66

77
# Host path for Zotero WebDAV data mounted into the proxy container
88
ZOTERO_ROOT_HOST_PATH=/volume1/Reference/zotero
@@ -12,7 +12,7 @@ PDF_PROXY_PORT=8280
1212
WEB_LIBRARY_PORT=8281
1313

1414
# Base URL that the Web Library should use when linking to the PDF proxy (update to your staging hostname/IP)
15-
PDF_PROXY_BASE_URL=http://nas.local:8280/pdf
15+
PDF_PROXY_BASE_URL=http://192.168.0.50:8280/pdf
1616

1717
# Metadata source (runtime templating for the Web Library)
1818
# If you host metadata on zotero.org (default):
@@ -31,3 +31,6 @@ ZOTERO_INCLUDE_MY_LIBRARY=true
3131
ZOTERO_INCLUDE_USER_GROUPS=true
3232
# JSON array of explicit libraries/groups to include (e.g., [{"key":"g123","name":"Team","isGroupLibrary":true}])
3333
ZOTERO_LIBRARIES_INCLUDE_JSON=[]
34+
35+
# On-prem upload gating (false = block WebUI uploads to Zotero Storage; use Desktop/WebDAV sync)
36+
WEB_LIBRARY_ALLOW_UPLOADS=false

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
**Self-hosted Zotero WebUI Opensource Library + WebDAV-based PDF viewer **Avoid storage fees, keep privacy, and still enjoy a full browser-based Zotero library.
44

55
## It is PoC
6-
It(v0.1.3) is still fully not properly works, please keep on watching to be ready for production.
6+
It(v0.1.5) is still fully not properly works, please keep on watching to be ready for production.
77

88
---
99

@@ -184,13 +184,16 @@ The sample data generator creates keys like `SAMPLE1` and `SAMPLE2` under `sampl
184184
- `.env.portainer.example``.env.portainer`: Portainer stack env file (same vars as staging) when deploying through the UI.
185185
- `.env` remains ignored; keep real secrets/paths out of the repo.
186186
- Metadata config (runtime templating for Web Library): `ZOTERO_API_KEY`, `ZOTERO_API_AUTHORITY_PART`, `ZOTERO_USER_SLUG`, `ZOTERO_USER_ID`, `ZOTERO_INCLUDE_MY_LIBRARY`, `ZOTERO_INCLUDE_USER_GROUPS`, and `ZOTERO_LIBRARIES_INCLUDE_JSON` let you point at zotero.org or an on-prem metadata source. Set these in your env file so the container renders `index.html` with the correct API host/user/groups.
187+
- Attachment routing: set `PDF_PROXY_BASE_URL` to a host the browser can reach (not localhost when accessing via NAS). Attachments/reader will use this proxy.
188+
- Upload gating: `WEB_LIBRARY_ALLOW_UPLOADS=false` (default in on-prem examples) hides/blocks WebUI uploads to Zotero Storage; use Zotero Desktop to sync attachments to WebDAV instead.
187189

188190
### Metadata configuration & troubleshooting
189191

190192
- Set `ZOTERO_USER_SLUG`/`ZOTERO_USER_ID` and `ZOTERO_API_AUTHORITY_PART` to the metadata host you want (defaults to zotero.org).
191193
- Control which libraries load with `ZOTERO_INCLUDE_MY_LIBRARY`, `ZOTERO_INCLUDE_USER_GROUPS`, and `ZOTERO_LIBRARIES_INCLUDE_JSON` (JSON array of `{ "key": "g123", "name": "Team", "isGroupLibrary": true }`).
192194
- Validate templating by viewing page source for `zotero-web-library-config`; wrong host/key/ID typically shows up as `Failed to fetch` in the browser console.
193195
- For on-prem hosts, ensure the metadata API is reachable from inside the container and that CORS allows the Web Library origin.
196+
- Attachments: ensure `PDF_PROXY_BASE_URL` is reachable from the browser; set `WEB_LIBRARY_ALLOW_UPLOADS=false` to avoid uploading to Zotero Storage and rely on Desktop/WebDAV sync for PDFs.
194197
---
195198

196199
## 🛠 Components

app/web-library-overlay/scripts/entrypoint.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ set -e
99
: "${ZOTERO_INCLUDE_MY_LIBRARY:=true}"
1010
: "${ZOTERO_INCLUDE_USER_GROUPS:=true}"
1111
: "${ZOTERO_LIBRARIES_INCLUDE_JSON:=[]}"
12+
: "${WEB_LIBRARY_ALLOW_UPLOADS:=false}"
1213
: "${PDF_PROXY_BASE_URL:=http://localhost:8280/pdf}"
1314

1415
# Render index.html with current env vars (placeholders remain if empty)
1516
if [ -f /usr/share/nginx/html/index.html ]; then
1617
envsubst \
17-
'$ZOTERO_API_KEY $ZOTERO_API_AUTHORITY_PART $ZOTERO_USER_SLUG $ZOTERO_USER_ID $ZOTERO_INCLUDE_MY_LIBRARY $ZOTERO_INCLUDE_USER_GROUPS $ZOTERO_LIBRARIES_INCLUDE_JSON $PDF_PROXY_BASE_URL' \
18+
'$ZOTERO_API_KEY $ZOTERO_API_AUTHORITY_PART $ZOTERO_USER_SLUG $ZOTERO_USER_ID $ZOTERO_INCLUDE_MY_LIBRARY $ZOTERO_INCLUDE_USER_GROUPS $ZOTERO_LIBRARIES_INCLUDE_JSON $WEB_LIBRARY_ALLOW_UPLOADS $PDF_PROXY_BASE_URL' \
1819
< /usr/share/nginx/html/index.html \
1920
> /usr/share/nginx/html/index.html.rendered
2021
mv /usr/share/nginx/html/index.html.rendered /usr/share/nginx/html/index.html

app/web-library-overlay/src/html/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"apiAuthorityPart": "$ZOTERO_API_AUTHORITY_PART"
2323
},
2424
"pdfProxyBaseUrl": "$PDF_PROXY_BASE_URL",
25+
"allowUploads": $WEB_LIBRARY_ALLOW_UPLOADS,
2526
"libraries": {
2627
"include": $ZOTERO_LIBRARIES_INCLUDE_JSON,
2728
"includeMyLibrary": $ZOTERO_INCLUDE_MY_LIBRARY,
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { getZotero } from 'web-common/zotero';
2+
import { fetchAllChildItems, fetchChildItems, getAttachmentUrl } from '.';
3+
import { cleanURL, get, getDOIURL, getItemFromApiUrl, openDelayedURL } from '../utils';
4+
import { makePath } from '../common/navigation';
5+
import { PDFWorker } from '../common/pdf-worker.js';
6+
import { REQUEST_EXPORT_PDF, RECEIVE_EXPORT_PDF, ERROR_EXPORT_PDF } from '../constants/actions';
7+
import { saveAs } from 'file-saver';
8+
import { READER_CONTENT_TYPES } from '../constants/reader';
9+
10+
const tryGetFirstLink = itemKey => {
11+
return async (dispatch, getState) => {
12+
const state = getState();
13+
let itemsByParent = get(state, ['libraries', state.current.libraryKey, 'itemsByParent', itemKey], null);
14+
if(!itemsByParent) {
15+
await dispatch(fetchChildItems(itemKey, { start: 0, limit: 100 }));
16+
itemsByParent = get(getState(), ['libraries', state.current.libraryKey, 'itemsByParent', itemKey], null);
17+
}
18+
if(itemsByParent && itemsByParent.keys.length > 0) {
19+
const firstAttachmentKey = itemsByParent.keys[0];
20+
const item = get(getState(), ['libraries', state.current.libraryKey, 'items', firstAttachmentKey], null);
21+
if(item && item.url) {
22+
return item.url;
23+
}
24+
}
25+
26+
return false;
27+
}
28+
}
29+
30+
// On-prem overlay: always prefer PDF proxy when configured, even if Zotero Storage links exist.
31+
const tryGetAttachmentURL = attachmentItemKey => {
32+
return async (dispatch, getState) => {
33+
const state = getState();
34+
const item = get(state, ['libraries', state.current.libraryKey, 'items', attachmentItemKey], null);
35+
36+
// If a proxy base URL is configured, bypass Storage/enclosure links and use the proxy.
37+
if (state.config?.pdfProxyBaseUrl) {
38+
return dispatch(getAttachmentUrl(attachmentItemKey));
39+
}
40+
41+
const isFile = item && item.linkMode && item.linkMode.startsWith('imported') && item[Symbol.for('links')].enclosure;
42+
const isLink = item && item.linkMode && item.linkMode === 'linked_url';
43+
const hasLink = isFile || isLink;
44+
45+
if(hasLink) {
46+
return dispatch(getAttachmentUrl(attachmentItemKey));
47+
}
48+
49+
return false;
50+
}
51+
}
52+
53+
const tryGetBestAttachmentURLFallback = itemKey => {
54+
return async (dispatch, getState) => {
55+
const state = getState();
56+
const item = get(state, ['libraries', state.current.libraryKey, 'items', itemKey], null);
57+
58+
if(item.url) {
59+
const url = cleanURL(item.url, true);
60+
if(url) {
61+
return url;
62+
}
63+
}
64+
65+
if(item.DOI) {
66+
const doi = getZotero().Utilities.cleanDOI(item.DOI);
67+
if(doi) {
68+
return getDOIURL(doi);
69+
}
70+
}
71+
72+
return dispatch(tryGetFirstLink(itemKey));
73+
}
74+
}
75+
76+
const pickBestItemAction = itemKey => {
77+
return async (dispatch, getState) => {
78+
const state = getState();
79+
const item = get(state, ['libraries', state.current.libraryKey, 'items', itemKey], null);
80+
const current = state.current;
81+
const attachment = get(item, [Symbol.for('links'), 'attachment'], null);
82+
if(attachment) {
83+
const attachmentItemKey = getItemFromApiUrl(attachment.href);
84+
if (Object.keys(READER_CONTENT_TYPES).includes(attachment.attachmentType)) {
85+
// "best" attachment is PDF, open in reader
86+
const readerPath = makePath(state.config, {
87+
attachmentKey: attachmentItemKey,
88+
collection: current.collectionKey,
89+
items: [itemKey],
90+
library: current.libraryKey,
91+
noteKey: null,
92+
publications: current.isMyPublications,
93+
qmode: current.qmode,
94+
search: current.search,
95+
tags: current.tags,
96+
trash: current.isTrash,
97+
view: 'reader',
98+
});
99+
return window.open(readerPath);
100+
} else if(attachment) {
101+
// "best" attachment exists, but is not PDF, open file
102+
return openDelayedURL(dispatch(getAttachmentUrl(attachmentItemKey)));
103+
}
104+
} else {
105+
// no "best" attachment, pick most appropriate fallback
106+
return openDelayedURL(dispatch(tryGetBestAttachmentURLFallback(itemKey)));
107+
}
108+
}
109+
}
110+
111+
const pickBestAttachmentItemAction = attachmentItemKey => {
112+
return async (dispatch, getState) => {
113+
const state = getState();
114+
const current = state.current;
115+
const item = get(state, ['libraries', state.current.libraryKey, 'items', attachmentItemKey], null);
116+
117+
const isFile = item && item.linkMode && item.linkMode.startsWith('imported') && item[Symbol.for('links')].enclosure;
118+
const isLink = item && item.linkMode && item.linkMode === 'linked_url';
119+
120+
if (isFile) {
121+
if (Object.keys(READER_CONTENT_TYPES).includes(item.contentType)) {
122+
const readerPath = makePath(state.config, {
123+
attachmentKey: null,
124+
collection: current.collectionKey,
125+
items: [attachmentItemKey],
126+
library: current.libraryKey,
127+
noteKey: null,
128+
publications: current.isMyPublications,
129+
qmode: current.qmode,
130+
search: current.search,
131+
tags: current.tags,
132+
trash: current.isTrash,
133+
view: 'reader',
134+
});
135+
return window.open(readerPath);
136+
} else {
137+
return openDelayedURL(dispatch(getAttachmentUrl(attachmentItemKey)));
138+
}
139+
}
140+
141+
if(isLink) {
142+
const url = cleanURL(item.url, true);
143+
if (url) {
144+
window.open(url);
145+
}
146+
}
147+
148+
return false;
149+
}
150+
}
151+
152+
const exportAttachmentWithAnnotations = itemKey => {
153+
return async (dispatch, getState) => {
154+
const libraryKey = getState().current.libraryKey;
155+
dispatch({
156+
type: REQUEST_EXPORT_PDF,
157+
itemKey,
158+
libraryKey
159+
});
160+
161+
try {
162+
const attachmentURL = await dispatch(getAttachmentUrl(itemKey));
163+
await dispatch(fetchAllChildItems(itemKey));
164+
const state = getState();
165+
const { pdfWorkerURL, pdfReaderCMapsURL, pdfReaderStandardFontsURL } = state.config;
166+
const childItems = state.libraries[state.current.libraryKey]?.itemsByParent[itemKey]?.keys ?? [];
167+
const allItems = state.libraries[state.current.libraryKey]?.items;
168+
const attachmentItem = allItems[itemKey];
169+
if (attachmentItem.contentType !== 'application/pdf') {
170+
throw new Error("Attachment is not a PDF");
171+
}
172+
const annotations = childItems
173+
.map(childItemKey => allItems[childItemKey])
174+
.filter(item => !item.deleted && item.itemType === 'annotation');
175+
176+
const pdfWorker = new PDFWorker({ pdfWorkerURL, pdfReaderCMapsURL, pdfReaderStandardFontsURL });
177+
const data = await (await fetch(attachmentURL)).arrayBuffer();
178+
const buf = await pdfWorker.export(data, annotations);
179+
const blob = new Blob([buf], { type: 'application/pdf' });
180+
const fileName = attachmentItem?.filename || 'file.pdf';
181+
saveAs(blob, fileName);
182+
183+
dispatch({
184+
type: RECEIVE_EXPORT_PDF,
185+
libraryKey,
186+
itemKey,
187+
fileName,
188+
blob,
189+
annotations
190+
});
191+
} catch(error) {
192+
dispatch({
193+
type: ERROR_EXPORT_PDF,
194+
libraryKey,
195+
itemKey,
196+
error
197+
});
198+
}
199+
}
200+
}
201+
202+
export { pickBestAttachmentItemAction, pickBestItemAction, exportAttachmentWithAnnotations, tryGetAttachmentURL }

0 commit comments

Comments
 (0)