Skip to content

Screenshot mirror returns an empty body for a quarter of all apps #26

Closed

Description

@nursoda

apps.json hands clients raw screenshot URLs; a Nextcloud instance never fetches those
itself but asks the mirror at
https://usercontent.apps.nextcloud.com/<base64 of the URL as it appears in apps.json>.
For a large share of apps that mirror answers HTTP 200 with an empty body, while the
source URL serves the image fine. Instances therefore show no screenshot in the app list.
The store website is unaffected — it embeds the source URL directly.

Steps to reproduce

curl -sL --compressed -o apps.json https://apps.nextcloud.com/api/v1/platform/34.0.0/apps.json
jq -r '.[] | .id as $i | .screenshots[]? | $i + "\t" + (.url|@base64)' apps.json |
while IFS=$'\t' read -r id b64; do
  n=$(curl -s "https://usercontent.apps.nextcloud.com/$b64" | head -c 20 | wc -c)
  [ "$n" -eq 0 ] && echo "empty: $id"
done

The base64 key must be built from the URL string exactly as it appears in apps.json,
including any surrounding whitespace — several apps write <screenshot> across multiple
lines in info.xml, and that whitespace is part of the key. (Whitespace itself is not the
bug: 80 of the 88 whitespace-carrying URLs are mirrored correctly.)

Expected behaviour

The mirror serves the image, as it does for the majority of apps.

Actual behaviour

Measured 2026-09-09 against /api/v1/platform/34.0.0/apps.json:

result screenshot URLs
image delivered 763
empty body (0 bytes) 318
body File not found (14 bytes) 5
total 1086 (from 381 apps)

97 of 381 apps with screenshots have at least one empty image.
Examples: moviedb, crate, n8n_sync, homecheck, maintenancecheck, twofactor_email,
folder_protection, kanso, integration_pexip, agora, llmchat, launchpad.

A random sample of ten affected URLs was fetched from their source: all ten returned
HTTP 200 with the full image (94–630 KB). So the source is not the problem.

Guess

A first fetch that failed appears to be cached as an empty entry and never retried.
Re-fetching on an empty cache entry, or not caching failures at all, would fix it.

Activity

  1. edward-ly commented on Sep 9, 2026

    @edward-ly
    Contributor

    Thanks for letting us know! First of all, this does indeed concern the usercontent.apps.nextcloud.com project rather than the app store itself, so I'm moving this issue over there. Second, I'm not sure if there's actually an issue with the current code, but some images may have slipped through the cracks due to recent changes to the project. Either way, it might be useful to add a check on the existing images and delete those with an empty or File not found body. If you're interested in working on this, we happily accept and review PRs.

  2. nursoda commented on Sep 10, 2026

    @nursoda
    Author

    Thanks for looking into it. Sorry for the wrong repo. I don't have time to work on it, found it while debugging my own app twofactor_email, and I'm quite busy with it. Not sure whether I should close here.

  3. added theissue type on Sep 24, 2026
  4. dillardblom commented on Oct 1, 2026

    @dillardblom

    Data that may help, from extended_occweb (release uploaded 2026-10-01 01:11 UTC). I can't tell whether #31 is deployed yet, so this only describes what the mirror returns.

    Source URL (in apps.json) Source Mirror
    https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot.png 200, image/png, 104 KB 200, empty body
    https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot-status.png 200, image/png, 97 KB 200, empty body
    https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot-sql-switch.png 200, image/png, 77 KB 200, empty body
    https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot-sql.png 200, image/png, 102 KB 200, empty body

    Until this release these keys answered File not found; since the release they answer with an empty body, so something now writes under the key the store hands out (which the double-encoding bug fixed in #31 would not do). They were still empty at 17:30 UTC, more than 16 hours after the release and after at least two of the 6-hour retry windows (RETRY_BACKOFF_S).

    Other apps with a recent release show the same split (first screenshot of each, checked 2026-10-01 12:05 UTC; the ones that work may have been cached under the same URL before):

    Newest release (UTC) App Source host Mirror
    2026-10-01 11:13 atrium_secureshare raw.githubusercontent.com empty
    2026-10-01 07:54 files_archive github.com/…/raw image
    2026-10-01 05:33 kanso raw.githubusercontent.com empty
    2026-10-01 03:12 ncc_backend_4mc raw.githubusercontent.com image
    2026-10-01 01:11 extended_occweb github.com/…/raw empty
    2026-10-01 00:17 file_checksum_search gitlab.com empty
    2026-09-30 22:37 google_synchronization github.com/…/raw image

    So it does not depend on the host or on the redirect from github.com/…/raw.

    Two things in the code that may explain the empty bodies:

    • An empty cache file means a failed fetch whose warning image could not be generated: generateWarningImage() writes '' when imagecreatetruecolor() returns false, for example when GD is not available to the sync process. If that is the case on the server, every failed fetch ends up as an empty body instead of the "Preview not available" image, which would also explain the quarter of empty screenshots measured above.
    • index.php sends Expires: Sun, 17 Jan 2038 for every file it serves, including empty ones. Once a fetch succeeds on a later retry, browsers and caches that already stored the empty response may keep showing nothing.

    That still leaves the question why these fetches fail while the source URLs serve the images fine; the sync output for one of the URLs above would probably show it.

    A quick deploy of a fix would be much appreciated. A user who opens an app page and sees no screenshots will first blame the developer for careless release work; that many apps are affected only becomes clear when you look around more widely. That reflects badly on developers who build and maintain apps in their own unpaid time, with their name attached to them, while the cause is elsewhere. The least we can hope for is that our reputation doesn't suffer from it. Thank you for looking into it, and thanks again to @martin-rueegg for #31.

  5. kreotropic commented on Oct 4, 2026

    @kreotropic
    Contributor

    I ran into this with my own apps and dug into it. I think the empty bodies come from file permissions in the cache, not from failed fetches.

    Survey (2026-10-04). I fetched every screenshot listed in /api/v1/apps.json (1652 URLs) through the mirror and from its source:

    • 491 come back as 200 with an empty body, across 150 apps.
    • For all 491, the source was a valid image when fetched. 471 still serve one today (max 1.8 MB, median 160 KB, all under MAX_SCREENSHOT_SIZE); the other 20 now return 404.
    • Source host, format, dimensions and whitespace in the URL make no difference.
    • The 9 fetches that really failed (source 404, HTML page, unreachable host) are served fine, as the 640×360 "Preview not available" PNG.

    When it started. Grouping apps by the week of their first release, which is roughly when their screenshot URLs first reached sync.php:

    first release served empty
    2026-W27 – W29 (Jun 29 – Jul 19) 53 2
    2026-W30 (Jul 20 – 26) 16 35
    2026-W31 – W40 (Jul 27 – Oct 4) 2 318

    So essentially every screenshot first synced after about Jul 21 is empty. Older ones are fine, apart from URLs that only appeared in a later release (my folder_protection has 3/5 working: the two empty ones first shipped in a release on Aug 2).

    Likely cause. An empty 200 means realpath() found the cache file, but file_get_contents() returned false. Since #20, a successful fetch is written with tempnam() and then rename()d into cache/. tempnam() creates the file with mode 0600, and rename() keeps that mode. If sync.php runs as a different user than the web server, which may have changed with the redeploy for #23/#24 around Jul 21, index.php can't read the file and echoes nothing. Warning images are written straight into cache/ by imagepng() with the default umask, which is why failures display and successes don't.

    I can't see the server, so this is inferred. It fits every data point above, and one ls -l can confirm it, e.g. for one of mine:

    ls -l cache/aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2tyZW90cm9waWMvZ3JvdXBfbWFuYWdlci92MC40LjEvc2NyZWVuc2hvdHMvMS1tZW1iZXJzLnBuZw==
    

    I'd expect -rw-------, owned by the user cron runs sync.php as.

    Possible fix.

    file_put_contents($tempUrl, $data);
    chmod($tempUrl, 0644);

    plus a one-off chmod 644 of the files already in cache/. Running sync.php as the web server user would also work.

    Examples (sources all return 200 image/png, mirror returns an empty 200):

    Happy to open a PR with the chmod if that helps.

  6. weltmaister commented on Oct 5, 2026

    @weltmaister

    Same here for photosphere_tours (release 0.4.11, uploaded 2026-10-02 19:19 UTC, after #31 was merged). All four screenshots return 200 with an empty body from the mirror, while the sources serve fine (e.g. https://raw.githubusercontent.com/weltmaister/photosphere_tours/master/screenshots/viewer.jpg → 200, image/jpeg, 52 KB). Could you trigger a refetch for this app? Thanks!

  7. edward-ly commented on Oct 5, 2026

    @edward-ly
    Contributor

    Happy to open a PR with the chmod if that helps.

    Yes, feel free to open a PR if you like! We'll review/merge it as soon as we can. Also, perhaps it would be nice to include in sync.php a way to check the permissions for existing cache entries and auto-update them to 0644 if needed (rather than having to manually update the entries ourselves).

    Could you trigger a refetch for this app? Thanks!

    Unfortunately, refetches are not possible in general with the way the cache currently works. A workaround would be to also change the URLs for any screenshots you wanna update.

  8. fds2610 commented on Oct 6, 2026

    @fds2610

    Another affected app: nc_gantt (Gantt Projects), release 2.3.0, uploaded 2026-09-30 11:31 UTC, i.e. after #31 was merged. This is the same app as in #29, which was closed after #31.

    All 10 screenshot URLs in apps.json now answer HTTP 200 with an empty body (application/octet-stream, 0 bytes). Until #31 they answered 404 File not found. The sources are fine (e.g. https://codeberg.org/fds2610/NC-Gantt/raw/tag/v2.3.0-b0879cb/screenshots/appstore/uc01-project-structure.png → 200, image/png, < 300 KB).

    This matches the tempnam() + rename() → mode 0600 explanation from the comment above, so #32 should fix it. Re-uploading doesn't help, since the cache is only filled once per URL.

    Checked on 2026-10-06 against the current apps.json. Happy to re-test once #32 is deployed.

  9. dillardblom commented on Oct 7, 2026

    @dillardblom

    One more data point, now that #32 is merged: ng_backup, first release (0.9.1) uploaded 2026-10-07 16:20 UTC.

    Source URL (in apps.json) Source Mirror
    https://raw.githubusercontent.com/dillardblom/ng_backup/main/screenshots/1-backups.png 200, image/png, 226 KB 200, empty body
    https://raw.githubusercontent.com/dillardblom/ng_backup/main/screenshots/2-restore.png 200, image/png, 243 KB 200, empty body
    https://raw.githubusercontent.com/dillardblom/ng_backup/main/screenshots/3-settings.png 200, image/png, 231 KB 200, empty body

    Shortly after the upload the mirror answered File not found (not synced yet); at 20:58 UTC, 1h20 after #32 was merged, all three answer an empty 200. So either #32 isn't deployed yet or sync.php hasn't run since. As ensureReadable() also repairs existing entries, these three should start working on the first sync after deployment without a new upload, which makes them a simple check that the deploy worked. I'll re-test then.

    Thanks @kreotropic and @edward-ly for #32.

  10. kreotropic commented on Oct 8, 2026

    @kreotropic
    Contributor

    Hi! Just confirmed that the fix worked! I had to open it in a private window because the cache is forever...

    Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions