quickdrop
AGENTS.md433 starsChanged 10 months agoMITView on GitHub
Self-hosted file sharing: Spring Boot 4.1 + Thymeleaf + SQLite (Flyway, Hibernate community dialect) + Tailwind. Java 21.
We split this AGENTS.md into the rules it gives an agent
4 rules. About 0 are common, 2 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 8
Don't restate what the code does, don't add rationale blocks to routine patterns, and don't explain what a previous version got wrong.
- Distinctive: line 10
Commit messages: subject line plus 2–3 short sentences.
- Very specific: line 14
Run: java -jar target/quickdrop.jar → http://localhost:8080.
- Very specific: line 38
Add new protected routes via the right interceptor: AdminPasswordSetupInterceptor (forces /admin/setup until admin pwd exists), …
≈ “Common” is an estimate: at least 1 in 5 of the 36,368 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
QuickDrop — Agent Guide
Self-hosted file sharing: Spring Boot 4.1 + Thymeleaf + SQLite (Flyway, Hibernate community dialect) + Tailwind. Java 21.
Code style
- Comment only where the code is genuinely ambiguous — a non-obvious constraint, a surprising "why", a trap someone would otherwise re-introduce. One or two terse lines.
- Don't restate what the code does, don't add rationale blocks to routine patterns, and don't explain what a previous version got wrong. That belongs in the commit message.
- Commit messages: subject line plus 2–3 short sentences.
Build & run
- Build:
./mvnw clean package(Windows:mvnw.cmd clean package). Output:target/quickdrop.jar. - Run:
java -jar target/quickdrop.jar→ http://localhost:8080. - Tailwind CSS:
npm run tw:buildrebuildssrc/main/resources/static/css/tailwind.cssfromtailwind-input.css. - Test suite:
./mvnw test(JUnit/MockMvc, ~630 tests undersrc/test/java/) — runs on every CI build via the Jenkinsfile'sBuild and Teststage and gates Docker publish. Full testing guide (conventions, live e2e instances, browser testing, i18n):docs/TESTING.md. HTTP/security probes and browser exploratory testing are separate, on-demand passes (not part of the automatic CI gate) — see the same doc plusscripts/seed_e2e.pyand thequickdrop-e2e*configs in.claude/launch.jsonfor isolated live instances (ports 8081–8084). Write findings from such a pass todocs/test-reports/— that path stays gitignored because reports may describe unpatched vulnerabilities. Browser-only logic that MockMvc cannot reach has a second, much smaller suite:npm test(node --test, no framework dependency) oversrc/test/js/, currently covering the archive-layout rules inupload/zip-builder.js.src/package.jsonexists only to mark that tree as ES modules for Node; it sits above the Maven resource root and is not packaged. - Logs at
log/quickdrop.log, DB atdb/quickdrop.db, uploads underfiles/, database backups underdb/backups/, custom branding underdb/branding/(all auto-created byQuickdropApplication; seeAppPaths). - Docker:
roastslav/quickdrop:latest, exposes 8080; mount/app/db,/app/files,/app/log— backups and branding live under/app/db, so no separate volume is needed for either; omitting/app/dbloses all three on container recreation.
Architecture (follow the layers)
- Controllers (
controller/):FileViewController(/file/**— upload form, list, detail, download, preview, history),AdminViewController(/admin/**— dashboard, file/paste management, settings,/admin/linksmerged short-links admin page),FileRestController(/api/file/**— chunked upload, upload status/abort, share APIs),PasteViewController(/file/paste/**— paste create/edit forms; paste viewing is stillFileViewController),ShareViewController(/share/{token}— share landing page + key auth),ShortLinkRestController(/api/link/**— QR codes for any short link,POST /api/linkcreates a redirect link),ShortLinkViewController(/link/new— create page;/s/{code}and/s/{code}/go— resolver and interstitial-confirm for redirect links, upload-share links forward to/share/{token}unchanged),StorageMigrationController(/admin/storage-migration**— storage backend migration UI/API),BackupController(/admin/backups**— database backup schedule overview, on-demand backup, restore, delete, download),PasswordViewController,IndexViewController. Thymeleaf views insrc/main/resources/templates/. - Services (
service/) own all business logic — there is no single do-everything service anymore. Route file mutations throughFileLifecycleService(save/delete/extend/hide/share-token issuance) so cache eviction, history logging, and notifications fire via its@CacheEvicts and privatelogHistoryhelper. ItsgenerateShareToken/revokeShareTokenmethods are thin wrappers that delegate the actual work toShortLinkService— kept inFileLifecycleServiceso no controller call site had to change when the short-link feature was carved out; add new upload-share-link logic toShortLinkService, not here. Reads/authorization go throughFileQueryService(@Cacheablelist/detail queries). Streaming downloads and share-token redemption go throughFileDownloadService, which does its own history logging/cache eviction for download events. - Upload pipeline is async:
POST /api/file/upload-chunk→AsyncFileMergeServicequeues the chunk on a background thread pool (submitChunk(..., waitForCompletion=false)) and the controller returns immediately —nullbody for intermediate chunks,202 Accepted {"status":"processing","uploadId":...}on the last chunk. The merge task writes chunks in order, running the stream throughEncryptionServicewhen password + encryption flag are set, then callsFileLifecycleService.saveFileon completion. Frontend must pollGET /api/file/upload-status/{uploadId}(AsyncFileMergeService.getUploadStatus, returns status/uuid/error/processedChunks/totalChunks) rather than expect a synchronous result;POST /api/file/upload-abortcancels an in-flight upload. Metadata fields:description,keepIndefinitely,password,hidden,fileSize, archive fields,isPaste. The archive fields (archiveUpload/archiveName/archiveManifest, still stored in thefolder_upload/folder_name/folder_manifestcolumns via@Column— renaming those needs a migration for no gain) carry any browser-built archive, not only a picked directory. Folder-vs-loose-bundle is deliberately not stored: it is derived from the manifest byFileUtils.summarizeArchive, which must keep matchingderiveRootFolderinupload/zip-builder.js— every file entry under one shared top-level directory means a folder, anything else a bundle. Change one and you must change the other, or a folder gets labelled a bundle on the very page showing its folder name. A bundle is namedfiles-<yymd>.zipby the browser (bundleDateStamp) and gets a four-character code appended byFileLifecycleService#distinctBundleNameon save — the server is the only place that can check the name is not already taken, so the browser never has to ask. Guardrails, all client-side constants except the last:MAX_ARCHIVE_FILES(1000) caps a selection,IN_MEMORY_ZIP_LIMIT(256 MB) is the point above which JSZip is abandoned for the streamed zip64 writer so a large archive is never materialised in the tab,INITIAL_TREE_LINES(500,archive-tree.js) collapses a long contents tree behind a "show more", andMAX_MANIFEST_LENGTH(1 MB,FileRestController) bounds the client-supplied manifest before it is parsed and stored. The max-file-size setting is enforced in three places, and only one of them is authoritative: the upload page checks the selection and then the built archive,MultipartConfigbounds a single chunk request (never the assembled upload, since every chunk is 4 MB), andAsyncFileMergeServicecounts the bytes it actually merges and fails the upload past the limit. Only the last one survives a caller that ignores the page —fileSizeis the client's word, so the controller's early check on it is a courtesy to honest clients, not a control. - Download:
/file/download/{uuid}streams with on-the-fly decryption and logs toactivity_log(renamed fromfile_history_log) viaActivityLog/ActivityLogRepository.EventTypenow spans several categories (FILE:UPLOAD/DOWNLOAD/RENEWAL/DELETION, PASTE, SHARE, ADMIN, SYSTEM), not just upload/renewal/download. Share links:POST /api/file/share/{uuid}issues a token, redeemed atGET /share/{token}(landing page,ShareViewController) with actual bytes served fromGET /api/file/download/{token}(FileRestController, decrements/expires, redirects back to/share/{token}if invalid). Theshare_tokentable/entity was renamed toshort_link/ShortLink— a single-table-inheritance base (discriminatortarget_type) that today has one subtype,UploadShareLink(the renamedShareTokenEntity, still served at/share/{token}). This is groundwork for a general URL-shortener feature (plain-URL redirect links) sharing the same code space, resolver, and admin page — seeShortLinkRepositoryfor the field-rename mapping (file→upload,shareToken→code,tokenExpirationDate→expirationDate,numberOfAllowedDownloads→remainingUses) — repository method names were deliberately kept unchanged via explicit@Querybodies so existing call sites didn't need to change. - Storage/model: UUID filenames on disk at
fileStoragePath(from settings);Uploadis an abstract JOINED-inheritance base entity (tableupload) holding shared metadata + optional BCrypt password hash +encryptedflag, extended byStoredFile(adds folder fields for browser-built ZIP archives — a picked folder or a multi-file selection;FileEntityViewcopies those fields plus a deriveditemCountso the list pages, the admin table and the share landing page can all show what an archive holds — the contents tree itself is drawn byarchive-tree.js, a self-mounting module any page can include) andPaste(addsimmutable,editOnly). - QR codes:
QrCodeServicerenders SVG by hand from zxing-core's raw module matrix (no AWT), and PNG by piping that SVG throughSvgRasterizationService— reuses its existing hardening rather than a second image path. Served for anyShortLinkviaGET /api/link/{code}/qr.{svg,png}(ShortLinkRestController).fileView.html's share-link QR used to be generated client-side by aqrcode@1.4.4script loaded fromcdn.jsdelivr.neton every file-view page load — replaced with an<img>pointing at this endpoint, removing that third-party call.batik-codecmust stay alongsidebatik-transcoderinpom.xml:SvgRasterizationService#rasterizeToPngwrites through Batik's ownImageWriterRegistry/SPI framework, not plainjavax.imageio, and that registry has no PNG writer without it — every test that mocksrasterizeToPng(nearly all of them) would still pass without this dependency, so removing it wouldn't be caught by the suite alone. - General link shortener:
RedirectLinkis the otherShortLinktarget type (plain URL redirects, vs.UploadShareLink). Created viaShortLinkService#createRedirectLink, which runs the destination throughLinkGuard(normalize + safety-check) before persisting, and resolved viaShortLinkService#resolveAndRecordVisit, which re-runsLinkGuard.checkForRedirect(DNS-rebinding recheck) and only then incrementsuseCount/decrementsremainingUses.ShortLinkViewController's resolver route is/s/{code}— a literal segment, not a{prefix}path variable, even though the prefix is meant to become configurable later: a variable first segment structurally matches any two-segment path, including static assets like/js/app.js, and Spring resolves annotated controllers before the static-resource handler, so a{prefix}pattern there would silently 404 every two-segment static asset site-wide — this actually happened during development and was only caught by live-browser verification, not by any MockMvc test, since none of them fetch real static assets through the dispatcher. Making the prefix configurable later needs a fixed allowlist of literal routes or a regex excluding reserved words (ShortCodeService's reserved-word list), not a bare path variable. Viewing the interstitial (short-link-preview.html) does not consume the link's use budget — onlyGET /s/{code}/go(or the admin-session direct-redirect path, which skips the interstitial) does, mirroring how/share/{token}(view) is separate from/api/file/download/{token}(the actual consuming action)./admin/links(AdminViewController) is the merged admin page for both link types, switched via akind=share|redirectquery param — two separate, simplerShortLinkRepositoryqueries (findFilteredforUploadShareLink,findFilteredRedirectLinksforRedirectLink) feeding one Thymeleaf template (admin-links.html) and one JS controller (admin-links.js), deliberately not a single polymorphic query across both subtypes./admin/share-links(the pre-merge URL) 302s to/admin/links.EventCategory.SHORTLINK(SHORTLINK_CREATE/SHORTLINK_VISIT/SHORTLINK_REVOKE/SHORTLINK_EXPIRE/SHORTLINK_BLOCKED) covers redirect-link analytics; visit logging (not theuseCount/remainingUsescounters, which always update) is gated byshortenerClickLoggingEnabled.ActivityLoggained a nullableshortLinkrelation (short_link_id) alongside its existingfilerelation for this.
Settings (single-row, id=1)
ApplicationSettingsServiceseeds defaults on startup (max 1 GB, 30-day life,files/logspaths, cron0 0 2 * * *, encryption on, default home = upload, SMTP/Discord off,backupScheduleEnabled=false,backupCron=0 0 4 * * *,maxBackups=7, etc.) and re-schedules cleanup when cron changes. Never insert a second settings row.- Template-level feature flags (file list enabled, app password set, admin button, encryption on, previews, pastebin) are injected by
GlobalControllerAdvice— read from there; don't duplicate. - Shortener settings:
shortenerEnabled(default on, open to all users — matchesuploadEnabled's posture),shortenerAdminOnly,shortenerCodeLength(default 5,RedirectLinkonly) andshareTokenLength(default 8, floored at 8,UploadShareLinkonly — guessing a share token yields the file, so it needs more entropy than a redirect code; both share one code space),shortenerCustomAliasEnabled/shortenerCustomAliasAdminOnly(aliases default admin-only even though the feature itself doesn't — a self-chosen slug is the impersonation risk a random code doesn't have),shortenerInterstitialMode(ALWAYS/NEVER/NON_ADMIN),shortenerDomainRuleMode/shortenerDomainRules(admin allow/blocklist, enforced byLinkGuardon both creation and every resolve — see its javadoc).shortenerEnabled/shortenerAdminOnlygate creation only (POST /api/link,GET /link/new);shortenerInterstitialModeand the domain rules keep applying to already-issued links even after the feature is disabled, since disabling stops minting new links but must not break ones already sent out — same posture asshareLinksEnabled.shortenerClickLoggingEnabled(default on) governsSHORTLINK_VISITaudit-log rows only, not the link's own use counters.trustedProxyEnabled(default off) governs whetherFileUtils#getRequesterInfohonoursX-Forwarded-For/X-Real-IPat all — every caller of that method (activity-log IP attribution,RateLimitInterceptor's bucket key) now goes through this single gate; trusting those headers unconditionally would let a direct client spoof its logged IP and defeat rate limiting by rotating the header per request, so it's opt-in for admins who've confirmed a real reverse proxy sits in front. - Threat-intelligence reputation checking:
ReputationCheckServiceis the facadeLinkGuardcalls on both creation and every resolve — it collects every@ServiceimplementingReputationProviderviaList<ReputationProvider>constructor injection (same auto-collection pattern asDelegatingStorageServicefor storage backends), so a fourth provider needs only its own class, nothing here. Three implementations, all shipped disabled:PhishingArmyProviderandUrlhausProviderextendAbstractHashFeedProvider(shared download/parse/two-tier-match machinery: a sortedlong[]ofFnv1aHashvalues for the hot-path lookup, confirmed against the raw feed file on disk only on a tier-1 hit — see that class's javadoc for the memory-footprint reasoning);SafeBrowsingProviderhas no feed and queries Google'shashes:searchAPI live per lookup instead, sending only a 4-byte hash prefix (never the URL) and caching verdicts in a small bounded LRU.ReputationSyncServicemirrorsBackupService's dynamic-cron pattern to keep the two feed-based providers refreshed onreputationFeedCron. A provider can only be turned on throughApplicationSettingsService#acceptReputationProviderTerms— the general settings save can only turn one off (which also clears its…TermsAcceptedAttimestamp, so re-enabling always re-prompts the licence-acceptance modal on the settings page); this is enforced server-side inupdateApplicationSettings, not just hidden in the UI.reputationCheckEnabledis the master switch;reputationFailClosed(default off — fail open) decides whether a provider that can't complete its check (feed never loaded, API unreachable) blocks or is skipped.
Security & interceptors
SecurityConfig: optional whole-app password at/password/login, BCrypt, CSRF cookie, permissive CORS. Session timeout applied per-session bySessionService#sessionCreated, reading the current setting live so a change takes effect for the next session created without a restart.- In-memory admin + file session tokens live in
SessionService(not persisted — restarts invalidate). - Add new protected routes via the right interceptor:
AdminPasswordSetupInterceptor(forces/admin/setupuntil admin pwd exists),AdminPasswordInterceptor(/admin/**+ file history),FilePasswordInterceptor(redirects to/file/password/{uuid}when the file has a password and no valid session token). RateLimitInterceptor(10/60s per IP+group, patterns inWebConfig):/share/**and/api/file/download/**share oneshare-downloadbucket — both consume the same share token, so separate buckets would double an enumerator's quota. Any new route that redeems a token belongs in that group too.
Migrations & persistence
- Flyway migrations in
src/main/resources/db/migration/V*__*.sql(currently up to V45 — check the directory for the latest). Baseline-on-migrate is enabled — append a newV{n+1}__...sql, never edit existing files. SQLite dialect isorg.hibernate.community.dialect.SQLiteDialect. - Entities in
entity/, Spring Data repos inrepository/. Use@Transactionalon service methods that mutate.
Pagination, search & cache
- Public list (
/file/list) and admin dashboard usepage/size/queryparams backed byPage<Upload>-returning service methods onFileQueryService— reuse them and pass all three params through to templates. - Spring Cache keys are
publicFiles,adminFiles,adminDeletedFiles,adminPastes,adminDeletedPastes,analytics,applicationSettings(seeCacheConfig). Any upload, deletion, renewal, visibility/keep toggle, or download must evict the relevant ones — copy the@CacheEvictpattern fromFileLifecycleService/FileDownloadService.
Frontend (templates, styling, dialogs)
- Every page starts from
fragments/layout.html, never a hand-written<head>. A page is:<head th:replace="~{fragments/layout :: head(~{::title}, ~{::head-extra})}"> <title th:text="#{page.x.title.browser(${appName})}">X</title> <th:block th:fragment="head-extra"><!-- page-only assets, or pass ~{} above --></th:block> </head> <body> <th:block th:replace="~{fragments/layout :: chrome(false)}"></th:block> <!-- skip link + nav + orbs --> <main id="main-content">…</main> <th:block th:replace="~{fragments/layout :: pageScripts}"></th:block> </body>headalready supplies charset/viewport, theme preload, both stylesheets, favicon,tailwindTheme.js,toast.js,confirm.js, Alpine and htmx — don't re-include any of them per page.head-extrais emitted before Alpine core, because Alpine plugins must register before it initialises (upload.html's@alpinejs/collapsedepends on this ordering). Passchrome(true)on pages behindAdminPasswordInterceptor(the visitor is an authenticated admin, so the nav shows the full admin link set);chrome(false)elsewhere./admin/passwordis the login gate itself, so it staysfalse. Pages with no nav at all (welcome,app-password,admin-backup-restoring) use theorbsfragment instead ofchrome. Admin sub-pages get their "← Back" button from thebackLink(href, label)fragment. - Styling goes in
@layer componentsintailwind-input.css, never an inline<style>block. Reach for the existing component classes (page-wrap*,card*,btn*,form-*,badge-*,data-table,stat-hero*,gate-icon*,modal*,markdown-content) before writing new utility soup.custom.cssis only for third-party widget internals that can't go through tokens (EasyMDE/CodeMirror, highlight.js). Rebuild withnpm run tw:buildafter any change, and commit the regeneratedtailwind.css— it is a checked-in build artifact. - Every page's content container is
.page-wrap, optionally plus one width modifier:page-wrap-medium(72rem),page-wrap-narrow(64rem),page-wrap-tight(32rem); bare.page-wrapis 80rem. It encodes the responsive padding (px-4 sm:px-6 lg:px-8,py-8 lg:py-12) once — don't hand-rollmax-w-… mx-auto px-…on a page again. - Component-layer rules are author CSS and are not tree-shaken, so anything unused ships to every visitor. The layer is currently at zero dead classes; if you add a colour variant, use it or drop it. The only classes with no literal reference in templates/JS are
.toast--*and.modal--*, whichtoast.js/confirm.jsbuild as`toast--${kind}`/`modal--${tone}`— they carry comments saying so. Before deleting a class, grepth:classappendtoo:badge-warningandgate-icon--indigowere both referenced but never defined, rendering unstyled for a long time, which is the mirror image of the same drift. - Never call
alert(),confirm(), orprompt().js/confirm.jsis loaded on every page and owns both:- Confirmations →
window.confirmAction({title, body, emphasis, confirmLabel, cancelLabel, tone})returns aPromise<boolean>;toneisdanger/warning/primary. It focus-traps, closes on Escape/backdrop, restores focus, and focuses Cancel on open so a stray Enter can't confirm a destructive action.bodyandemphasisaccept a string or an array of paragraphs and are always inserted astextContent, so interpolating a filename or URL is safe. - Prefer the declarative form where the action is a plain form or link — no page script at all: put
data-confirmplusdata-confirm-title/-body/-emphasis/-label/-cancel/-toneon the<form>(intercepts submit) or on a button/link (intercepts click). Both listeners are delegated fromdocument, so markup swapped in by htmx orspa.jskeeps working. - Plain "something went wrong" messages →
window.notify(message, kind), which forwards to the existingwindow.toast. Shared button labels (confirm,cancel,delete,revoke) are translated once intowindow.QD_CONFIRM_I18Nby the layout — read them from there instead of adding another per-page i18n blob.
- Confirmations →
Scheduling & notifications
@EnableScheduling+ScheduleServicehandles expiry deletion (cron from settings), missing-file row cleanup, and short-link expiry (cleanShortLinks, daily 03:30 — covers bothUploadShareLinkandRedirectLink; the former also gets its encrypted sidecar removed, the latter gets aSHORTLINK_EXPIREaudit-log row per link since it has noUploadto carry history on otherwise). Reschedule by calling itsrescheduleCleanup(...)when cron changes.NotificationServicebatches email + Discord webhook events per configured interval; call it fromFileLifecycleService/FileDownloadService, not controllers.BackupServicemirrorsScheduleService's dynamic-cron pattern (ownThreadPoolTaskScheduler, reschedules onSettingsChangedEvent) but for database backups — opt-in, viaVACUUM INTO(not a raw file copy, since WAL mode can leave data in the-walsidecar). Prunes down tomaxBackupsafter every backup or upload.uploadBackuplets an admin import an externally-supplied.dbfile (another instance, an off-site copy) into the same pool, under a generated filename so it's indistinguishable from a self-made backup.restoreBackupvalidates the candidate withPRAGMA quick_checkbefore touching the live DB — every candidate, uploaded or self-made, there's no special-casing by origin — clears stale-wal/-shmsidecars, then copies the backup overdb/quickdrop.db;BackupControllerlogsBACKUP_CREATED/BACKUP_UPLOADED/BACKUP_RESTORED/BACKUP_FAILED(newEventType.ADMINentries) and triggers a delayedSystem.exit(0)via theRestartTriggerseam (real impl:ProcessExitRestartTrigger) on a successful restore, relying onrestart: unless-stoppedto bring the process back.
i18n
- Bundles:
messages.properties(default/English),messages_bg.properties,messages_de.properties,messages_es.properties,messages_fr.properties,messages_it.properties,messages_ja.properties,messages_zh.properties. Add every new user-facing string to all eight (seedocs/i18n.md). Resolver wired inI18nConfig.
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 28,693 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repositoryOne other file it gives its agents.
- .github/copilot-instructions.mdCopilot instructions
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

