diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..2038611ce --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +server/.gradle +server/build +server/bin +server/upstream +website/node_modules +website/dist diff --git a/.gitignore b/.gitignore index 8656a4cb4..3845ae385 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ .helm charts/openvsx/charts +.gradle +build/ +bin/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..31c1cc9f0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "server/upstream"] + path = server/upstream + url = https://github.com/gnugomez/openvsx.git + branch = poc/eclipse-extraction diff --git a/Dockerfile b/Dockerfile index 2a0d0040b..f02d5a0e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG SERVER_VERSION=1e8f4f6 +ARG SERVER_VERSION=poc/eclipse-extraction ARG SERVER_VERSION_STRING=v1.1.0-dev.3 # Builder image to compile the website @@ -26,15 +26,60 @@ RUN cd website \ && yarn install --immutable \ && yarn build -# Main image derived from openvsx-server -FROM ghcr.io/eclipse-openvsx/openvsx-server-snapshot:${SERVER_VERSION} +# Upstream server sources at the ref given by SERVER_VERSION, consumed as a library +# by the Gradle build stage below. To build from a local checkout instead of cloning +# (e.g. the 'upstream' submodule or a sibling working copy): +# docker build --build-context server-src=server/upstream . +FROM alpine/git:latest AS server-clone +ARG SERVER_REPO=https://github.com/gnugomez/openvsx.git ARG SERVER_VERSION +RUN git clone --filter=blob:none ${SERVER_REPO} /src \ + && git -C /src checkout ${SERVER_VERSION} + +FROM scratch AS server-src +COPY --from=server-clone /src / + +# Build the server application against the upstream library (composite build) +FROM eclipse-temurin:25-jdk AS server-builder + +WORKDIR /workdir + +COPY --from=server-src / upstream/ +COPY server/gradlew server/settings.gradle server/build.gradle ./ +COPY server/gradle/ gradle/ +COPY server/src/ src/ + +ENV CI=true + +RUN ./gradlew --no-daemon -PopenvsxServerPath=upstream/server bootJar \ + && mkdir exploded \ + && cd exploded \ + && jar -xf ../build/libs/openvsx-server.jar + +# Main image: plain JRE plus the exploded server archive, replicating the layout of +# the upstream-derived image this used to build FROM +FROM eclipse-temurin:25-jre ARG SERVER_VERSION_STRING +# Create user openvsx and set up home directory +RUN groupadd -r openvsx \ + && useradd --no-log-init -r -g openvsx openvsx \ + && mkdir -p /home/openvsx/server \ + && chown -R openvsx:openvsx /home/openvsx + +USER openvsx +WORKDIR /home/openvsx/server + +COPY --chown=openvsx:openvsx --from=server-builder /workdir/exploded/ ./ +COPY --chown=openvsx:openvsx --from=server-src /server/scripts/run-server.sh ./ + COPY --from=builder --chown=openvsx:openvsx /workdir/website/dist/ BOOT-INF/classes/static/ COPY --from=builder --chown=openvsx:openvsx /workdir/configuration/application.yml config/ COPY --from=builder --chown=openvsx:openvsx /workdir/configuration/logback-spring.xml BOOT-INF/classes/ COPY --from=builder --chown=openvsx:openvsx /workdir/mail-templates BOOT-INF/classes/mail-templates -# Replace version placeholder with arg value -RUN sed -i "s//${SERVER_VERSION_STRING}/g" config/application.yml +# Replace version placeholder with arg value; make the start script executable +RUN chmod u+x run-server.sh \ + && sed -i "s//${SERVER_VERSION_STRING}/g" config/application.yml + +ENTRYPOINT ["./run-server.sh"] diff --git a/server/NOTES.md b/server/NOTES.md new file mode 100644 index 000000000..f7661b6f6 --- /dev/null +++ b/server/NOTES.md @@ -0,0 +1,268 @@ +# PoC: open-vsx.org as a Spring Boot app on top of the OSS registry + +This branch demonstrates that `EclipseFdn/open-vsx.org` can run as its own Spring +Boot application that consumes `eclipse-openvsx/openvsx` (the `server` project) as a +library, contributing deployment-specific code via Spring Boot auto-configuration. +The functionality used to prove it: the Eclipse publisher agreement, extracted from +upstream's `org.eclipse.openvsx.eclipse` into this repository. + +Paired branches: + +- upstream: `poc/eclipse-extraction` (gnugomez/openvsx, fork of eclipse-openvsx/openvsx) +- instance: `poc/openvsx-eclipse-module` (this repository; gnugomez/open-vsx.org) + +## How to build and run locally + +All Gradle machinery lives under `server/` (mirroring the upstream repo layout); +the repo root stays website + deployment config. The upstream server is consumed +as source through a Gradle composite build over the `server/upstream` git +submodule, pinned to the paired upstream branch. Any other checkout can be used +instead with `-PopenvsxServerPath=/server`. + +```bash +# fresh clone, no other checkouts needed +git clone --recurse-submodules -b poc/openvsx-eclipse-module +cd open-vsx.org/server +./gradlew test # incl. booting the merged app on Testcontainers PostgreSQL +./gradlew bootJar # the deployable jar + +# dev server on the host JVM, like upstream's `./gradlew runServer` +# (first run generates upstream's gitignored dev profile automatically) +docker compose -f upstream/docker-compose.yml up -d postgres +./gradlew runServer # http://localhost:8080 + +# Docker image (from the repo root): by default the server-src stage clones +# SERVER_REPO at SERVER_VERSION (the pinned fork branch), so this works with no +# local upstream at all +docker build -t openvsx-website:poc . + +# or build offline from the submodule / any local checkout +docker build --build-context server-src=server/upstream -t openvsx-website:poc . +``` + +In production the composite build would be replaced by a published +`org.eclipse.openvsx:openvsx-server` artifact — see the productionizing section. + +### Baseline (recorded before any change) + +- upstream `main` (36de5ace): `./gradlew build` — BUILD SUCCESSFUL, 798 tests, 2m33s + (Testcontainers; Docker required) +- instance `aws-main` (90036b9): `cd website && yarn install --immutable && yarn build` + — built in ~5s + +## Packaging design + +- `server/` is a single-project Spring Boot build. Its `bootJar` (named + `openvsx-server.jar` like upstream's) has the upstream server and all its + dependencies in `BOOT-INF/lib` and (eventually) only the deployment-specific + classes in `BOOT-INF/classes`. +- Main class is upstream's `org.eclipse.openvsx.RegistryApplication` — the module + deliberately has no `@SpringBootApplication` of its own. +- The Spring Boot plugin version and the Java version are parsed out of the upstream + checkout's `gradle/libs.versions.toml` in `settings.gradle`; this build declares + neither independently. +- No `application.yml` is packaged in the module jar. Configuration keeps flowing + through the image's `config/application.yml` (copied from `configuration/`, version + placeholder sed-replaced) plus `spring.config.import: file:${DEPLOYMENT_CONFIG}`, + exactly as today. +- The final Docker image replicates the upstream-derived image byte for byte in + layout: exploded boot jar in `/home/openvsx/server`, upstream's `run-server.sh` + as entrypoint (`java -cp BOOT-INF/classes:BOOT-INF/lib/* ...`), website dist at + `BOOT-INF/classes/static/`, logback config and mail templates in + `BOOT-INF/classes/`. Helm charts, ESO secrets and environment variables are + untouched. The base image is a plain JRE (`eclipse-temurin:25-jre`) instead of + `ghcr.io/eclipse-openvsx/openvsx-server-snapshot`. + +### Packaging parity evidence + +`BOOT-INF/lib` of the instance bootJar is identical to upstream's bootJar except for +`openvsx-server-plain.jar` itself (upstream ships those classes as `BOOT-INF/classes` +instead). `BOOT-INF/classes` of the module jar is empty at the packaging-only +milestone. + +## Upstream changes (the "consumable library" enablement) + +1. **Publish the `java` component** (`from components.java`): the plain jar + (classifier `plain`, already produced by the Boot plugin) plus real dependency + metadata in the POM/Gradle module metadata. The executable `bootJar` stays the + main artifact, so nothing changes for existing consumers of the publication. +2. **Expose the effective dependency versions to consumers.** The + `io.spring.dependency-management` plugin (managed BOM versions, declared-version + pins) applies only inside the upstream project; a consumer resolving the library + would find versionless dependencies it cannot resolve at all. The fix (applied + with `java-library` so the constraints reach both the api and runtime variants): + mirror the effective managed versions — Spring Boot BOM + upstream's property + overrides, with explicitly declared versions winning like they do upstream — as + plain dependency constraints on `api`. + The constraints are deliberately **not strict**. A first attempt with + `strictly` pins blew up: a strict constraint does not downgrade a *sibling* + dependency edge that requires a higher version — it fails resolution — and + Jackson 3's Gradle module metadata (fetched lazily; failures appeared only after + the metadata landed in the cache, which made them look nondeterministic) requires + e.g. `woodstox-core 7.1.1` while upstream pins 6.4.0. Where Maven-like + "managed version wins" resolution differs from Gradle's highest-version-wins, + parity is enforced on the *instance* side with a short, documented + `resolutionStrategy.force` list (gson, woodstox-core, the two CVE range floors, + and test-only byte-buddy/mockito) in `server/build.gradle`, verified by + diffing `BOOT-INF/lib` against an upstream bootJar. + The tomcat→jetty module replacement (`modules { replacedBy ... }`) cannot be + exported at all — Gradle component metadata rules are project-local — so the + instance module repeats those 3 lines. Without it, Tomcat lands on the classpath + alongside Jetty and Spring Boot silently auto-configures Tomcat. + +## Seams introduced upstream + +Every point where core upstream code called into `org.eclipse.openvsx.eclipse` was +converted to one of two small, generically named interfaces (or the code moved out +entirely). What a third-party deployment could do with them is noted per seam. + +### 1. `org.eclipse.openvsx.publish.PublisherAgreementService` (interface, all methods default no-op) + +Consumed via `@Nullable PublisherAgreementService` constructor injection with a +no-op anonymous default (`publisherAgreement != null ? publisherAgreement : new +PublisherAgreementService() {}`), so a vanilla registry runs without extra +configuration. `@Nullable` (the repo's existing pattern for optional beans, see +`SimilarityCheckService`) was chosen over `Optional<>` because Mockito's +`@InjectMocks` cannot supply `Optional` constructor parameters — `Optional<>` broke +`AdminServiceTest`. The instance auto-configuration contributes `EclipseService` as +the implementation. Call sites: + +| method | caller | purpose | +|---|---|---| +| `checkPublisherAgreement(user)` | `LocalRegistryService.createNamespace` / `.publish` | the publishing gate | +| `enrichUserJsonWithPublisherAgreement(json, user)` | `UserAPI.getUserData` (`GET /user`) | agreement status in the profile response | +| `adminEnrichUserJson(json, user)` | `AdminService.getUserPublishInfo` | agreement status in the admin view | +| `revokePublisherAgreement(user, admin)` | `AdminService.revokePublisherContributions` | external revocation on admin action | + +The `isActive() && eclipsePersonId != null` guard that used to sit in `AdminService` +moved *inside* the implementation — the interface contract is "called +unconditionally, implementation decides". A third-party deployment could implement +this to require any kind of publisher vetting (a CLA, a paid plan, a manual allow +list) without touching upstream. + +### 2. `org.eclipse.openvsx.security.OAuth2LoginHandler` (interface) + +`OAuth2UserServices`, `SecurityConfig` and `CustomAuthenticationSuccessHandler` +previously hard-coded the `"eclipse"` registration id in three behaviors. They now +consume a `List` (empty by default) keyed by +`getRegistrationId()`: + +- `loadUser(userRequest)` — replaces the `case "eclipse" -> loadEclipseUser(...)` + switch arm; registrations without a handler use the generic attribute-mapping flow. +- `authenticationSucceeded(principal, accessToken, refreshToken)` — replaces the + event-listener branch that stored the Eclipse token. +- `getSuccessRedirectUrl(defaultTargetUrl)` — replaces the hard-coded post-login + redirect to `/user-settings/profile`. + +The instance contributes `EclipseLoginHandler`, which links the Eclipse account to +the logged-in GitHub user (profile fetch, GitHub-handle cross-check), stores the +token, and redirects to the profile page. A third party could use the same SPI for +any "secondary account linking" provider. The `ECLIPSE_MISSING_GITHUB_ID` / +`ECLIPSE_MISMATCH_GITHUB_ID` error codes moved out of upstream's +`CodedAuthException` into the handler (the wire format is unchanged — they were +plain strings). + +### 3. Moves without a seam + +- `POST /user/publisher-agreement` existed solely for the agreement → the endpoint + moved verbatim to `PublisherAgreementAPI` in this module (same path, same + request/response shapes, same CSRF posture). On top of the move, the module + contributes a "Publisher Agreement" Swagger UI group (`GroupedOpenApi` bean in the + auto-configuration) documenting the endpoint — upstream's groups only cover + `/api/**`, `/vscode/**` and `/admin/**`, so `/user/**` endpoints were never in the + Swagger UI. The extra dropdown entry doubles as a visible marker that the registry + is running with the Eclipse extension; it disappears with the auto-configuration + (covered by the negative test). +- `PublisherComplianceChecker` (the `ovsx.eclipse.check-compliance-on-start` startup + check) only depends on public upstream services → moved wholesale. +- `EclipseService`, `EclipseTokenService` and the DTOs + (`EclipseProfile`, `PublisherAgreement`, `PublisherAgreementResponse`, + `SignAgreementParam`) moved to `org.eclipsefdn.openvsx.eclipse` unchanged apart + from the package statement, the `PublisherAgreementService` implementation + declaration and the relocated revocation guard. All `ovsx.eclipse.*` configuration + keys are unchanged. + +### Accepted residue upstream (documented, not moved) + +- `UserData.eclipsePersonId` / `UserData.eclipseToken` — database columns; the PoC + brief forbids schema changes. Productionizing the extraction fully would need a + generic "linked account / auth token" storage or instance-owned persistence. +- `UserJson.PublisherAgreement` — part of the public API response shape consumed by + the web UI; treated as a generic "publisher agreement" concept in the API model. + The seam interface reuses it, so it arguably belongs upstream anyway. + +## Verification results + +- **Upstream suite** (`./gradlew build`, Testcontainers): green before the change + (798 tests) and green after the extraction (784 tests — the missing 14 are + `EclipseServiceTest`, relocated here and passing). +- **Instance build** (`./gradlew test` in `server/`): all green — + the 14 relocated `EclipseServiceTest` cases, a `@SpringBootTest` booting the + merged application against Testcontainers PostgreSQL (upstream endpoints respond, + `POST /user/publisher-agreement` is mapped, `PublisherAgreementService` resolves + to `EclipseService`, the `eclipse` login handler and compliance checker are + registered), and the negative test (auto-configuration excluded via + `spring.autoconfigure.exclude` → application healthy, agreement bean and endpoint + absent). +- **Dependency parity**: `BOOT-INF/lib` of the module's bootJar is identical to an + upstream bootJar's, except `openvsx-server-plain.jar` itself (whose classes are + upstream's `BOOT-INF/classes`). +- **Docker image**: boots on a plain JRE base with a production-shaped + `DEPLOYMENT_CONFIG`; Jetty (not Tomcat) serves; website, `/user`, + `/login-providers`, `/api/version` (sed-substituted version string) and database + search respond as before; the agreement endpoint is mapped. +- **Helm/ESO**: `git diff aws-main..HEAD -- charts kubernetes dashboards + configuration mail-templates Jenkinsfile` is empty. + +## Gotchas encountered + +- Upstream's `bootJar`/`jar` names carry no version (`version` is unset), so the + library publishes as `org.eclipse.openvsx:openvsx-server:unspecified`. Composite + builds don't care (substitution ignores versions), but real artifact publication + needs a version scheme. +- The upstream main jar ships **no** `application.yml` (only `src/dev` and + `src/test` do), so the "no application.yml in the instance jar" rule is naturally + satisfied; the deployment already gets its base config from `config/application.yml` + in the image. +- Named Docker build contexts (`--build-context server-src=…`) replace the + `server-src` stage wholesale; the stage is normalized (`FROM scratch` + + `COPY --from=server-clone`) so both the clone default and the local override + present the same layout to later stages. +- Gradle rich-version gotcha (cost the most time of anything here): a `strictly` + constraint does not downgrade a sibling dependency edge that requires a higher + version — resolution fails. And because Jackson 3 ships Gradle module metadata + that is only fetched when first needed, the failures appeared a build *after* the + change that triggered the fetch. Hence plain constraints + instance-side forces. +- Boot 4 modularization details surface in a consumer that upstream never sees: + `TestRestTemplate` lives in `spring-boot-resttestclient` (via + `spring-boot-starter-webmvc-test`), Testcontainers 2.x uses + `org.testcontainers:testcontainers-postgresql` (not 1.x `:postgresql`), and + Mockito cannot `@InjectMocks` an `Optional<>` constructor parameter (hence the + `@Nullable` seam injection upstream). +- The instance module compiles against Spring/Jakarta/etc. directly, so it declares + those dependencies itself (versionless, resolved via the server's published + constraints) instead of leaning on upstream's `implementation` classpath leaking + through. +- **The sneakiest one:** `bootJar` hoists the application's own `META-INF/**` + resources to the *jar root*, not `BOOT-INF/classes` — and `run-server.sh` launches + with `java -cp BOOT-INF/classes:BOOT-INF/lib/*`, which never sees the exploded + jar root. The auto-configuration registration + (`META-INF/spring/….AutoConfiguration.imports`) silently vanished from the + runtime classpath: the container booted healthy but *without* the publisher + agreement, while every Gradle-run test (which uses the plain resources dir) + passed. Caught only by smoke-testing the real image. Fixed by copying + `META-INF/spring/**` into `BOOT-INF/classes` in the `bootJar` task; the + moved-endpoint probe is part of the container smoke test now. + +## Productionizing (honest assessment) + +- Publish `org.eclipse.openvsx:openvsx-server` (plain jar + POM + module metadata) + to a real repository (Maven Central or GitHub Packages) with a version scheme; + the composite build is a stopgap that compiles upstream from source on every image + build. +- CI: the instance build needs a pinned upstream ref (build arg `SERVER_VERSION`) + and a cache for Gradle dependencies; the current Dockerfile downloads everything + per build, like upstream's own Dockerfile. +- Upgrade workflow: bumping upstream = bumping one ref/version and re-running the + instance test suite; API-breaking upstream changes surface as compile errors in + the instance build instead of image-assembly surprises. diff --git a/server/build.gradle b/server/build.gradle new file mode 100644 index 000000000..bb0a547ef --- /dev/null +++ b/server/build.gradle @@ -0,0 +1,121 @@ +plugins { + id 'java' + id 'org.springframework.boot' +} + +group = 'org.eclipsefdn.openvsx' + +java { + sourceCompatibility = gradle.ext.javaVersion +} + +repositories { + mavenCentral() +} + +dependencies { + // The upstream registry server, consumed as a library. No version: the composite + // build (settings.gradle) substitutes the included server project. + implementation 'org.eclipse.openvsx:openvsx-server' + + // What this module's own sources compile against. No versions: the server + // library publishes its effective managed versions as dependency constraints. + implementation "org.springframework.boot:spring-boot-starter-webmvc" + implementation "org.springframework.boot:spring-boot-starter-data-jpa" + implementation "org.springframework.security:spring-security-oauth2-client" + implementation "org.apache.commons:commons-lang3" + implementation "com.fasterxml.jackson.core:jackson-annotations" + implementation "tools.jackson.core:jackson-databind" + implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui" + + // Mirrors the server's tomcat -> jetty replacement; component module rules are + // project-local upstream and do not propagate to consumers. + modules { + module("org.springframework.boot:spring-boot-starter-tomcat") { + replacedBy("org.springframework.boot:spring-boot-starter-jetty") + } + } + + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation "org.springframework.boot:spring-boot-starter-webmvc-test" + testImplementation "org.springframework.boot:spring-boot-testcontainers" + testImplementation "org.testcontainers:testcontainers-junit-jupiter" + testImplementation "org.testcontainers:testcontainers-postgresql" + testImplementation "io.micrometer:micrometer-core" + testImplementation "org.jobrunr:jobrunr-spring-boot-4-starter" +} + +// The server's io.spring.dependency-management plugin resolves its graph with +// Maven-like semantics: managed and declared versions beat newer transitive +// requests. That does not travel to consumers, where Gradle picks the highest +// requested version. Force the few modules where the two disagree so this build +// resolves exactly what the upstream bootJar ships (the two ranges are upstream's +// CVE floors). Verified by diffing BOOT-INF/lib against an upstream bootJar; see +// NOTES.md. The mockito/byte-buddy entries only matter on the test classpaths +// (upstream's tests force-downgrade them the same way). +configurations.configureEach { + resolutionStrategy { + force 'com.google.code.gson:gson:2.13.2' + force 'com.fasterxml.woodstox:woodstox-core:6.4.0' + force 'org.apache.commons:commons-compress:[1.26.0,2.0)' + force 'org.eclipse.parsson:parsson:[1.1.8,2.0)' + force 'net.bytebuddy:byte-buddy:1.17.8' + force 'org.mockito:mockito-core:5.20.0' + force 'org.mockito:mockito-junit-jupiter:5.20.0' + } +} + +test { + jvmArgs = ['--enable-native-access=ALL-UNNAMED', '-Xmx2048m'] + useJUnitPlatform() +} + +// Same developer entry point as upstream's `./gradlew runServer`: runs the registry +// on the host JVM with upstream's dev configuration (src/dev/resources), plus this +// module's beans. Expects the dev services from upstream's docker-compose.yml +// (postgres), exactly like the upstream task does. +tasks.register('runServer', JavaExec) { + jvmArgs = [ + '-Dorg.jooq.no-logo=true', + '-Dorg.jooq.no-tips=true', + '--enable-native-access=ALL-UNNAMED' // due to https://github.com/netty/netty/issues/15161 + ] + classpath = sourceSets.main.runtimeClasspath + files(new File(gradle.ext.openvsxServerDir, 'src/dev/resources')) + mainClass = 'org.eclipse.openvsx.RegistryApplication' + + // Upstream's one-time dev bootstrap: the dev profile includes the gitignored + // application-ovsx.properties, which developers generate in their checkout. + // Generate it on first run so a fresh submodule works out of the box. + doFirst { + def serverDir = gradle.ext.openvsxServerDir as File + if (!new File(serverDir, 'src/dev/resources/application-ovsx.properties').exists()) { + def generate = new ProcessBuilder('bash', 'scripts/generate-properties.sh') + .directory(serverDir) + .inheritIO() + .start() + if (generate.waitFor() != 0) { + throw new GradleException("${serverDir}/scripts/generate-properties.sh failed") + } + } + } +} + +springBoot { + // The application class is upstream's; this module only layers deployment-specific + // beans on top via auto-configuration. Do not add a second @SpringBootApplication. + mainClass = 'org.eclipse.openvsx.RegistryApplication' +} + +bootJar { + // Keep the artifact name the Docker image entrypoint expects. + archiveFileName = 'openvsx-server.jar' + + // bootJar hoists this module's META-INF resources to the jar root, but the + // image entrypoint launches with -cp BOOT-INF/classes:BOOT-INF/lib/* (no jar + // root on the classpath), which would silently drop the auto-configuration + // registration. Keep a copy under BOOT-INF/classes. + from(sourceSets.main.resources) { + include 'META-INF/spring/**' + into 'BOOT-INF/classes' + } +} diff --git a/server/gradle/wrapper/gradle-wrapper.jar b/server/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..b1b8ef56b Binary files /dev/null and b/server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/server/gradle/wrapper/gradle-wrapper.properties b/server/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..df6a6ad76 --- /dev/null +++ b/server/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/server/gradlew b/server/gradlew new file mode 100755 index 000000000..b9bb139f7 --- /dev/null +++ b/server/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/server/gradlew.bat b/server/gradlew.bat new file mode 100644 index 000000000..aa5f10b06 --- /dev/null +++ b/server/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/server/settings.gradle b/server/settings.gradle new file mode 100644 index 000000000..38f269894 --- /dev/null +++ b/server/settings.gradle @@ -0,0 +1,36 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } + resolutionStrategy { + eachPlugin { + // Keep the Spring Boot plugin in lockstep with the upstream server build. + // The version is derived below from the server's version catalog. + if (requested.id.id == 'org.springframework.boot') { + useVersion(gradle.ext.springBootVersion) + } + } + } +} + +rootProject.name = 'open-vsx-org' + +// The upstream server, consumed as a library through a Gradle composite build: +// the 'upstream' submodule by default, or any checkout via -PopenvsxServerPath. +def serverDir = file(providers.gradleProperty('openvsxServerPath').getOrElse('upstream/server')) +if (!new File(serverDir, 'gradle/libs.versions.toml').exists()) { + throw new GradleException("No openvsx server build at ${serverDir}. Run" + + " 'git submodule update --init' or pass -PopenvsxServerPath=/server.") +} + +// Also used by the runServer task (dev configuration). +gradle.ext.openvsxServerDir = serverDir + +// The Spring Boot and Java versions are derived from the upstream version catalog, +// never declared here. +def tomlText = new File(serverDir, 'gradle/libs.versions.toml').text +gradle.ext.springBootVersion = (tomlText =~ /(?m)^spring-boot\s*=\s*"([^"]+)"/).collect { it[1] }.first() +gradle.ext.javaVersion = (tomlText =~ /(?m)^java\s*=\s*"([^"]+)"/).collect { it[1] }.first() + +includeBuild(serverDir) diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseFoundationAutoConfiguration.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseFoundationAutoConfiguration.java new file mode 100644 index 000000000..36e537d8e --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseFoundationAutoConfiguration.java @@ -0,0 +1,98 @@ +/******************************************************************************** + * Copyright (c) 2026 Eclipse Foundation and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import jakarta.persistence.EntityManager; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.client.RestTemplate; + +import org.eclipse.openvsx.ExtensionService; +import org.eclipse.openvsx.UserService; +import org.eclipse.openvsx.repositories.RepositoryService; + +/** + * Registers the Eclipse Foundation publisher agreement integration on top of the + * upstream registry. The package is outside upstream's component scan, so every + * bean is declared explicitly here and the class is registered in + * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}. + */ +@AutoConfiguration +public class EclipseFoundationAutoConfiguration { + + @Bean + public EclipseTokenService eclipseTokenService( + TransactionTemplate transactions, + EntityManager entityManager, + ObjectProvider clientRegistrationRepository + ) { + return new EclipseTokenService(transactions, entityManager, clientRegistrationRepository.getIfAvailable()); + } + + @Bean + public EclipseService eclipseService( + EclipseTokenService tokens, + ExtensionService extensions, + EntityManager entityManager, + @Qualifier("restTemplate") RestTemplate restTemplate + ) { + return new EclipseService(tokens, extensions, entityManager, restTemplate); + } + + @Bean + public EclipseLoginHandler eclipseLoginHandler( + EclipseService eclipse, + EclipseTokenService tokens, + EntityManager entityManager + ) { + return new EclipseLoginHandler(eclipse, tokens, entityManager); + } + + @Bean + public PublisherAgreementAPI publisherAgreementAPI(UserService users, EclipseService eclipse) { + return new PublisherAgreementAPI(users, eclipse); + } + + /** + * Extra Swagger UI group for the endpoint this deployment contributes; upstream's + * groups (see its DocumentationConfig) are untouched. Also serves as a visible + * marker that the registry is running with the Eclipse extension. + */ + @Bean + public GroupedOpenApi publisherAgreementOpenApi(OpenApiCustomizer sortSchemasAlphabetically) { + var description = "Eclipse Foundation publisher agreement management," + + " contributed by the open-vsx.org deployment on top of the open-source registry."; + return GroupedOpenApi.builder() + .group("publisher-agreement") + .displayName("Publisher Agreement") + .pathsToMatch("/user/publisher-agreement") + .addOpenApiCustomizer( + openApi -> openApi.getInfo().title("Eclipse Publisher Agreement API").description(description)) + .addOpenApiCustomizer(sortSchemasAlphabetically) + .build(); + } + + @Bean + public PublisherComplianceChecker publisherComplianceChecker( + TransactionTemplate transactions, + EntityManager entityManager, + RepositoryService repositories, + ExtensionService extensions, + EclipseService eclipseService + ) { + return new PublisherComplianceChecker(transactions, entityManager, repositories, extensions, eclipseService); + } +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseLoginHandler.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseLoginHandler.java new file mode 100644 index 000000000..58ccda330 --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseLoginHandler.java @@ -0,0 +1,108 @@ +/******************************************************************************** + * Copyright (c) 2026 Eclipse Foundation and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import jakarta.persistence.EntityManager; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; + +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.security.CodedAuthException; +import org.eclipse.openvsx.security.IdPrincipal; +import org.eclipse.openvsx.security.OAuth2LoginHandler; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.UrlUtil; + +import static org.eclipse.openvsx.security.CodedAuthException.NEED_MAIN_LOGIN; + +/** + * Handles the 'eclipse' OAuth2 registration: it links an Eclipse Foundation + * account to the already logged-in user instead of creating a new account, and + * stores the Eclipse access token for publisher agreement API requests. + */ +public class EclipseLoginHandler implements OAuth2LoginHandler { + + public static final String ECLIPSE_MISSING_GITHUB_ID = "eclipse-missing-github-id"; + public static final String ECLIPSE_MISMATCH_GITHUB_ID = "eclipse-mismatch-github-id"; + + private final EclipseService eclipse; + private final EclipseTokenService tokens; + private final EntityManager entityManager; + + public EclipseLoginHandler(EclipseService eclipse, EclipseTokenService tokens, EntityManager entityManager) { + this.eclipse = eclipse; + this.tokens = tokens; + this.entityManager = entityManager; + } + + @Override + public String getRegistrationId() { + return "eclipse"; + } + + @Override + public IdPrincipal loadUser(OAuth2UserRequest userRequest) { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new CodedAuthException( + "Please log in with GitHub before connecting your Eclipse account.", + NEED_MAIN_LOGIN); + } + if (!(authentication.getPrincipal() instanceof IdPrincipal)) { + throw new CodedAuthException("The current authentication is invalid.", NEED_MAIN_LOGIN); + } + var principal = (IdPrincipal) authentication.getPrincipal(); + var userData = entityManager.find(UserData.class, principal.getId()); + if (userData == null) { + throw new CodedAuthException("The current authentication has no backing data.", NEED_MAIN_LOGIN); + } + try { + var accessToken = userRequest.getAccessToken().getTokenValue(); + var profile = eclipse.getUserProfile(accessToken); + if (StringUtils.isEmpty(profile.getGithubHandle())) { + throw new CodedAuthException( + "Your Eclipse profile is missing a GitHub username.", + ECLIPSE_MISSING_GITHUB_ID); + } + if (!profile.getGithubHandle().equalsIgnoreCase(userData.getLoginName())) { + throw new CodedAuthException( + "The GitHub username setting in your Eclipse profile (" + + profile.getGithubHandle() + + ") does not match your GitHub authentication (" + + userData.getLoginName() + ").", + ECLIPSE_MISMATCH_GITHUB_ID); + } + + eclipse.updateUserData(userData, profile); + return principal; + } catch (ErrorResultException exc) { + throw new AuthenticationServiceException(exc.getMessage(), exc); + } + } + + @Override + public void authenticationSucceeded( + IdPrincipal principal, + OAuth2AccessToken accessToken, + OAuth2RefreshToken refreshToken + ) { + tokens.updateEclipseToken(principal.getId(), accessToken, refreshToken); + } + + @Override + public String getSuccessRedirectUrl(String defaultTargetUrl) { + // Redirect to user profile page after login to Eclipse + return UrlUtil.createApiUrl(defaultTargetUrl, "user-settings", "profile"); + } +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseProfile.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseProfile.java new file mode 100644 index 000000000..a6623d19d --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseProfile.java @@ -0,0 +1,186 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import java.util.List; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonProperty; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.annotation.JsonDeserialize; + +public class EclipseProfile { + + private String uid; + + private String name; + + private String mail; + + private String picture; + + @JsonProperty("first_name") + private String firstName; + + @JsonProperty("last_name") + private String lastName; + + @JsonProperty("full_name") + private String fullName; + + @JsonProperty("github_handle") + private String githubHandle; + + @JsonProperty("twitter_handle") + private String twitterHandle; + + @JsonProperty("publisher_agreements") + @JsonDeserialize(using = PublisherAgreements.Deserializer.class) + private PublisherAgreements publisherAgreements; + + public String getUid() { + return uid; + } + + public void setUid(String uid) { + this.uid = uid; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getMail() { + return mail; + } + + public void setMail(String mail) { + this.mail = mail; + } + + public String getPicture() { + return picture; + } + + public void setPicture(String picture) { + this.picture = picture; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getGithubHandle() { + return githubHandle; + } + + public void setGithubHandle(String githubHandle) { + this.githubHandle = githubHandle; + } + + public String getTwitterHandle() { + return twitterHandle; + } + + public void setTwitterHandle(String twitterHandle) { + this.twitterHandle = twitterHandle; + } + + public PublisherAgreements getPublisherAgreements() { + return publisherAgreements; + } + + public void setPublisherAgreements(PublisherAgreements publisherAgreements) { + this.publisherAgreements = publisherAgreements; + } + + public Optional getOpenVsxPublisherAgreement() { + if (publisherAgreements != null && publisherAgreements.getOpenVsx() != null) { + return Optional.of(publisherAgreements.getOpenVsx()); + } else { + return Optional.empty(); + } + } + + public static class PublisherAgreements { + + @JsonProperty("open-vsx") + private PublisherAgreement openVsx; + + public PublisherAgreement getOpenVsx() { + return openVsx; + } + + public void setOpenVsx(PublisherAgreement openVsx) { + this.openVsx = openVsx; + } + + public static class Deserializer extends ValueDeserializer { + + private static final TypeReference> TYPE_LIST_AGREEMENT = new TypeReference<>() { + }; + + @Override + public PublisherAgreements deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException { + if (p.currentToken() == JsonToken.START_ARRAY) { + var list = ctxt.readValue(p, TYPE_LIST_AGREEMENT); + var result = new PublisherAgreements(); + if (!list.isEmpty()) { + result.openVsx = list.getFirst(); + } + return result; + } + return ctxt.readValue(p, PublisherAgreements.class); + } + + } + } + + public static class PublisherAgreement { + private String version; + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + } +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseService.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseService.java new file mode 100644 index 000000000..47e31c5fe --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseService.java @@ -0,0 +1,545 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import java.net.URI; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.json.JsonMapper; + +import org.eclipse.openvsx.ExtensionService; +import org.eclipse.openvsx.entities.AuthToken; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.json.UserJson; +import org.eclipse.openvsx.publish.PublisherAgreementService; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.HttpHeadersUtil; +import org.eclipse.openvsx.util.TimeUtil; + +public class EclipseService implements PublisherAgreementService { + + private static final String VAR_PERSON_ID = "personId"; + + public static final DateTimeFormatter CUSTOM_DATE_TIME = new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .append(DateTimeFormatter.ISO_LOCAL_DATE) + .appendLiteral(' ') + .append(DateTimeFormatter.ISO_LOCAL_TIME) + .toFormatter(); + + private static final TypeReference> TYPE_LIST_STRING = new TypeReference<>() { + }; + private static final TypeReference> TYPE_LIST_PROFILE = new TypeReference<>() { + }; + private static final TypeReference> TYPE_LIST_AGREEMENT = new TypeReference<>() { + }; + + protected final Logger logger = LoggerFactory.getLogger(EclipseService.class); + + private final EclipseTokenService tokens; + private final ExtensionService extensions; + private final EntityManager entityManager; + private final RestTemplate restTemplate; + private final JsonMapper jsonMapper; + + @Value("${ovsx.eclipse.base-url:}") + String eclipseApiUrl; + + @Value("${ovsx.eclipse.publisher-agreement.version:}") + String publisherAgreementVersion; + + @Value("${ovsx.eclipse.publisher-agreement.allowed-versions:}") + List publisherAgreementAllowedVersions; + + public EclipseService( + EclipseTokenService tokens, + ExtensionService extensions, + EntityManager entityManager, + RestTemplate restTemplate + ) { + this.tokens = tokens; + this.extensions = extensions; + this.entityManager = entityManager; + this.restTemplate = restTemplate; + this.jsonMapper = JsonMapper.builder().build(); + } + + public boolean isActive() { + return !StringUtils.isEmpty(publisherAgreementVersion) && !publisherAgreementAllowedVersions.isEmpty(); + } + + /** + * Check whether the given user has an active publisher agreement. + * @throws ErrorResultException if the user has no active agreement + */ + @Override + public void checkPublisherAgreement(UserData user) { + if (!isActive()) { + return; + } + // Users without authentication provider have been created directly in the DB, + // so we skip the agreement check in this case. + if (user.getProvider() == null) { + return; + } + var personId = user.getEclipsePersonId(); + if (personId == null) { + throw new ErrorResultException( + "You must log in with an Eclipse Foundation account and sign a Publisher Agreement before publishing any extension."); + } + + var json = user.toUserJson(); + enrichUserJsonWithPublisherAgreement(json, user); + var publisherAgreement = json.getPublisherAgreement(); + + if (publisherAgreement == null || publisherAgreement.getStatus().equals("none")) { + throw new ErrorResultException( + "You must sign a Publisher Agreement with the Eclipse Foundation before publishing any extension."); + } + + if (!publisherAgreement.getStatus().equals("signed")) { + if (publisherAgreement.getVersion() != null) { + throw new ErrorResultException( + "Your Publisher Agreement with the Eclipse Foundation is outdated (version " + + publisherAgreement.getVersion() + "). The current version is " + + publisherAgreementVersion + "."); + } else { + throw new ErrorResultException("Your Publisher Agreement with the Eclipse Foundation is outdated."); + } + } + } + + /** + * Get the publicly available user profile. + */ + public EclipseProfile getPublicProfile(String personId) { + var urlTemplate = buildApiUrl("account/profile/{personId}"); + var uriVariables = Map.of(VAR_PERSON_ID, personId); + var request = new HttpEntity(HttpHeadersUtil.getAcceptJsonHeaders()); + + try { + var response = restTemplate.exchange(urlTemplate, HttpMethod.GET, request, String.class, uriVariables); + return parseEclipseProfile(response); + } catch (RestClientException exc) { + if (exc instanceof HttpStatusCodeException) { + var status = ((HttpStatusCodeException) exc).getStatusCode(); + if (status == HttpStatus.NOT_FOUND) { + throw new ErrorResultException( + "No Eclipse profile data available for user '" + personId + "': " + exc.getMessage()); + } + } + + var url = UriComponentsBuilder.fromUriString(urlTemplate).build(uriVariables); + logger.error("Get request failed with URL: {}", url, exc); + throw new ErrorResultException( + "Request for retrieving user profile failed: " + exc.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * Update the given user data with a profile obtained from Eclipse API. + */ + @Transactional + public void updateUserData(UserData user, EclipseProfile profile) { + user = entityManager.merge(user); + user.setEclipsePersonId(profile.getName()); + } + + @Override + public void enrichUserJsonWithPublisherAgreement(UserJson json, UserData user) { + var usableToken = true; + PublisherAgreement agreement = null; + try { + // Add information on the publisher agreement + agreement = getPublisherAgreement(user); + } catch (ErrorResultException e) { + if (e.getStatus() == HttpStatus.FORBIDDEN) { + usableToken = false; + } else { + logger.warn("Failed to retrieve publisher agreement", e); + } + } + + // If we do not have a valid access token, access the public profile to find a signed OpenVSX publisher agreement. + // Note: this service uses cached data so it might not reflect the actual situation. + if (!usableToken) { + var eclipsePersonId = user.getEclipsePersonId(); + if (eclipsePersonId != null) { + try { + var profile = getPublicProfile(user.getEclipsePersonId()); + var publisherAgreement = profile.getOpenVsxPublisherAgreement(); + if (publisherAgreement.isPresent()) { + agreement = new PublisherAgreement(true, null, publisherAgreement.get().getVersion(), null); + } + } catch (ErrorResultException e) { + // public profile could not be retrieved for the user, could be blocked. + logger.warn(e.getMessage()); + } + } + } + + enrichUserJson(json, user, agreement, usableToken); + } + + public void enrichUserJson(UserJson json, UserData user, PublisherAgreement agreement) { + enrichUserJson(json, user, agreement, true); + } + + /** + * Enrich the given JSON user data with Eclipse-specific information. + */ + private void enrichUserJson(UserJson json, UserData user, PublisherAgreement agreement, boolean usableToken) { + if (!isActive()) { + return; + } + + var publisherAgreement = new UserJson.PublisherAgreement(); + publisherAgreement.setStatus("none"); + json.setPublisherAgreement(publisherAgreement); + + var personId = user.getEclipsePersonId(); + if (personId == null) { + return; + } + + if (agreement != null && agreement.isActive() && agreement.version() != null) { + var status = publisherAgreementAllowedVersions.contains(agreement.version()) ? "signed" : "outdated"; + publisherAgreement.setStatus(status); + } + + if (agreement != null) { + publisherAgreement.setVersion(agreement.version()); + } + + if (agreement != null && agreement.timestamp() != null) { + publisherAgreement.setTimestamp(TimeUtil.toUTCString(agreement.timestamp())); + } + + // Report user as logged in only if there is a usable token: + // we need the token to access the Eclipse REST API + if (usableToken) { + var eclipseLogin = new UserJson(); + eclipseLogin.setProvider("eclipse"); + eclipseLogin.setLoginName(personId); + if (json.getAdditionalLogins() == null) { + json.setAdditionalLogins(new ArrayList<>(List.of(eclipseLogin))); + } else { + json.getAdditionalLogins().add(eclipseLogin); + } + } + } + + @Override + public void adminEnrichUserJson(UserJson json, UserData user) { + if (!isActive()) { + return; + } + + var publisherAgreement = new UserJson.PublisherAgreement(); + var personId = user.getEclipsePersonId(); + if (personId == null) { + publisherAgreement.setStatus("none"); + return; + } + + try { + var profile = getPublicProfile(personId); + var openVsxPublisherAgreement = profile.getOpenVsxPublisherAgreement(); + if (openVsxPublisherAgreement.isEmpty() + || StringUtils.isEmpty(openVsxPublisherAgreement.get().getVersion())) { + publisherAgreement.setStatus("none"); + } else if (publisherAgreementAllowedVersions.contains(openVsxPublisherAgreement.get().getVersion())) { + publisherAgreement.setStatus("signed"); + } else { + publisherAgreement.setStatus("outdated"); + } + + json.setPublisherAgreement(publisherAgreement); + } catch (ErrorResultException e) { + logger.error("Failed to get public profile", e); + } + } + + /** + * Get the user profile available through an access token. + */ + public EclipseProfile getUserProfile(String accessToken) { + var requestUrl = buildApiUrl("openvsx/profile"); + var headers = HttpHeadersUtil.getAcceptJsonHeaders(); + headers.setBearerAuth(accessToken); + var request = new RequestEntity<>(headers, HttpMethod.GET, URI.create(requestUrl)); + + try { + var response = restTemplate.exchange(request, String.class); + return parseEclipseProfile(response); + } catch (RestClientException exc) { + logger.error("Get request failed with URL: {}", requestUrl, exc); + throw new ErrorResultException( + "Request for retrieving user profile failed: " + exc.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + private EclipseProfile parseEclipseProfile(ResponseEntity response) { + var json = response.getBody(); + if (json == null) { + return new EclipseProfile(); + } + + try { + if (json.startsWith("[\"")) { + var error = jsonMapper.readValue(json, TYPE_LIST_STRING); + logger.error("Profile request failed:\n{}", json); + throw new ErrorResultException( + "Request to the Eclipse Foundation server failed: " + error, + HttpStatus.INTERNAL_SERVER_ERROR); + } else if (json.startsWith("[")) { + var profileList = jsonMapper.readValue(json, TYPE_LIST_PROFILE); + if (profileList.isEmpty()) { + throw new ErrorResultException( + "No Eclipse user profile available.", + HttpStatus.INTERNAL_SERVER_ERROR); + } + return profileList.getFirst(); + } else { + return jsonMapper.readValue(json, EclipseProfile.class); + } + } catch (JacksonException exc) { + logger.error("Failed to parse JSON response ({}):\n{}", response.getStatusCode(), json, exc); + throw new ErrorResultException( + "Parsing Eclipse user profile failed: " + exc.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * Get the publisher agreement of the given user with the user's current access token. + */ + public PublisherAgreement getPublisherAgreement(UserData user) { + var eclipseToken = checkEclipseToken(user); + var personId = user.getEclipsePersonId(); + if (StringUtils.isEmpty(personId)) { + return null; + } + var urlTemplate = buildApiUrl("openvsx/publisher_agreement/{personId}"); + var uriVariables = Map.of(VAR_PERSON_ID, personId); + var headers = HttpHeadersUtil.getAcceptJsonHeaders(); + headers.setBearerAuth(eclipseToken.accessToken()); + var request = new HttpEntity<>(headers); + + try { + var json = restTemplate.exchange(urlTemplate, HttpMethod.GET, request, String.class, uriVariables); + return parseAgreementResponse(json); + } catch (RestClientException exc) { + HttpStatusCode status = HttpStatus.INTERNAL_SERVER_ERROR; + if (exc instanceof HttpStatusCodeException) { + status = ((HttpStatusCodeException) exc).getStatusCode(); + // The endpoint yields 404 if the specified user has not signed a publisher agreement + if (status == HttpStatus.NOT_FOUND) { + return null; + } + } + + var url = UriComponentsBuilder.fromUriString(urlTemplate).build(uriVariables); + logger.error("Get request failed with URL: {}", url, exc); + throw new ErrorResultException( + "Request for retrieving publisher agreement failed: " + exc.getMessage(), + status); + } + } + + private static final Pattern STATUS_400_MESSAGE = Pattern + .compile("400 Bad Request: \\[\\[\"(?[^\"]+)\"]]"); + + /** + * Sign the publisher agreement on behalf of the given user. + */ + public PublisherAgreement signPublisherAgreement(UserData user) { + var requestUrl = buildApiUrl("openvsx/publisher_agreement"); + var eclipseToken = checkEclipseToken(user); + var headers = HttpHeadersUtil.getAcceptJsonHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(eclipseToken.accessToken()); + var data = new SignAgreementParam(publisherAgreementVersion, user.getLoginName()); + var request = new HttpEntity<>(data, headers); + + try { + var json = restTemplate.postForEntity(requestUrl, request, String.class); + + // The request was successful: reactivate all previously published extensions + extensions.reactivateExtensions(user); + + // Parse the response and store the publisher agreement metadata + return parseAgreementResponse(json); + } catch (RestClientException exc) { + String message = exc.getMessage(); + var statusCode = HttpStatus.INTERNAL_SERVER_ERROR; + if (exc instanceof HttpStatusCodeException) { + var excStatus = ((HttpStatusCodeException) exc).getStatusCode(); + // The endpoint yields 409 if the specified user has already signed a publisher agreement + if (excStatus == HttpStatus.CONFLICT) { + message = "A publisher agreement is already present for user " + user.getLoginName() + "."; + statusCode = HttpStatus.BAD_REQUEST; + } else if (excStatus == HttpStatus.BAD_REQUEST) { + var matcher = STATUS_400_MESSAGE.matcher(exc.getMessage()); + if (matcher.matches()) { + message = matcher.group("message"); + } + } + } + if (statusCode == HttpStatus.INTERNAL_SERVER_ERROR) { + message = "Request for signing publisher agreement failed: " + message; + } + + String payload; + try { + payload = jsonMapper.writeValueAsString(data); + } catch (JacksonException exc2) { + payload = "<" + exc2.getMessage() + ">"; + } + logger.error("Post request failed with URL: {} Payload: {}", requestUrl, payload, exc); + throw new ErrorResultException(message, statusCode); + } + } + + private PublisherAgreement parseAgreementResponse(ResponseEntity response) { + var json = response.getBody(); + if (json == null) { + return null; + } + + try { + PublisherAgreementResponse agreementResponse; + if (json.startsWith("[\"")) { + var error = jsonMapper.readValue(json, TYPE_LIST_STRING); + logger.error("Publisher agreement request failed:\n{}", json); + throw new ErrorResultException( + "Request to the Eclipse Foundation server failed: " + error, + HttpStatus.INTERNAL_SERVER_ERROR); + } else if (json.startsWith("[")) { + var profileList = jsonMapper.readValue(json, TYPE_LIST_AGREEMENT); + if (profileList.isEmpty()) { + throw new ErrorResultException( + "No publisher agreement available.", + HttpStatus.INTERNAL_SERVER_ERROR); + } + agreementResponse = profileList.getFirst(); + } else { + agreementResponse = jsonMapper.readValue(json, PublisherAgreementResponse.class); + } + + var timestamp = parseDate(agreementResponse.effectiveDate); + return new PublisherAgreement( + TimeUtil.getCurrentUTC().isAfter(timestamp), + agreementResponse.documentID, + agreementResponse.version, + timestamp); + } catch (JacksonException exc) { + logger.error("Failed to parse JSON response ({}):\n{}", response.getStatusCode(), json, exc); + throw new ErrorResultException( + "Parsing publisher agreement response failed: " + exc.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + private LocalDateTime parseDate(String dateString) { + try { + return LocalDateTime.parse(dateString, CUSTOM_DATE_TIME); + } catch (DateTimeParseException exc) { + logger.error("Failed to parse timestamp.", exc); + return null; + } + } + + /** + * Revoke the given user's publisher agreement. If an admin user is given, + * the admin's access token is used for the Eclipse API request, otherwise + * the access token of the target user is used. + */ + @Override + public void revokePublisherAgreement(UserData user, UserData admin) { + if (!isActive() || user.getEclipsePersonId() == null) { + return; + } + checkEclipseData(user); + + var eclipseToken = admin == null ? checkEclipseToken(user) : checkEclipseToken(admin); + var headers = new HttpHeaders(); + headers.setBearerAuth(eclipseToken.accessToken()); + var request = new HttpEntity<>(headers); + var urlTemplate = buildApiUrl("openvsx/publisher_agreement/{personId}"); + var uriVariables = Map.of(VAR_PERSON_ID, user.getEclipsePersonId()); + + try { + var requestCallback = restTemplate.httpEntityCallback(request); + restTemplate.execute(urlTemplate, HttpMethod.DELETE, requestCallback, null, uriVariables); + } catch (RestClientException exc) { + var url = UriComponentsBuilder.fromUriString(urlTemplate).build(uriVariables); + logger.error("Delete request failed with URL: {}", url, exc); + throw new ErrorResultException( + "Request for revoking publisher agreement failed: " + exc.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + private void checkApiUrl() { + if (StringUtils.isEmpty(eclipseApiUrl)) { + throw new ErrorResultException("Missing URL for Eclipse API."); + } + } + + private String buildApiUrl(String path) { + checkApiUrl(); + + var baseUrl = eclipseApiUrl; + if (eclipseApiUrl.charAt(eclipseApiUrl.length() - 1) != '/') { + baseUrl += '/'; + } + + return baseUrl + path; + } + + private AuthToken checkEclipseToken(UserData user) { + var eclipseToken = tokens.getActiveEclipseToken(user); + if (eclipseToken == null || StringUtils.isEmpty(eclipseToken.accessToken())) { + throw new ErrorResultException("Authorization by Eclipse required.", HttpStatus.FORBIDDEN); + } + return eclipseToken; + } + + private void checkEclipseData(UserData user) { + if (StringUtils.isEmpty(user.getEclipsePersonId())) { + throw new ErrorResultException( + "Eclipse person ID is unavailable for user: " + + user.getProvider() + "/" + user.getLoginName()); + } + } +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseTokenService.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseTokenService.java new file mode 100644 index 000000000..dab14be6b --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/EclipseTokenService.java @@ -0,0 +1,158 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import jakarta.persistence.EntityManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.util.Pair; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; + +import org.eclipse.openvsx.entities.AuthToken; +import org.eclipse.openvsx.entities.UserData; + +public class EclipseTokenService { + + protected final Logger logger = LoggerFactory.getLogger(EclipseTokenService.class); + + private final TransactionTemplate transactions; + private final EntityManager entityManager; + private final ClientRegistrationRepository clientRegistrationRepository; + private final JsonMapper jsonMapper; + + public EclipseTokenService( + TransactionTemplate transactions, + EntityManager entityManager, + @Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository + ) { + this.transactions = transactions; + this.entityManager = entityManager; + this.clientRegistrationRepository = clientRegistrationRepository; + this.jsonMapper = JsonMapper.builder().build(); + } + + public AuthToken updateEclipseToken(long userId, OAuth2AccessToken accessToken, OAuth2RefreshToken refreshToken) { + var token = toAuthToken(accessToken, refreshToken); + return transactions.execute(status -> { + var userData = entityManager.find(UserData.class, userId); + userData.setEclipseToken(token); + return token; + }); + } + + private AuthToken toAuthToken(OAuth2AccessToken accessToken, OAuth2RefreshToken refreshToken) { + if (accessToken == null) { + return null; + } + + String refresh = null; + Instant refreshExpiresAt = null; + if (refreshToken != null) { + refresh = refreshToken.getTokenValue(); + refreshExpiresAt = refreshToken.getExpiresAt(); + } + + return new AuthToken( + accessToken.getTokenValue(), + accessToken.getIssuedAt(), + accessToken.getExpiresAt(), + accessToken.getScopes(), + refresh, + refreshExpiresAt); + } + + public AuthToken getActiveEclipseToken(UserData userData) { + var token = userData.getEclipseToken(); + if (token != null && isExpired(token.expiresAt())) { + OAuth2AccessToken newAccessToken = null; + OAuth2RefreshToken newRefreshToken = null; + var newTokens = refreshEclipseToken(token); + if (newTokens != null) { + newAccessToken = newTokens.getFirst(); + newRefreshToken = newTokens.getSecond(); + } + + return updateEclipseToken(userData.getId(), newAccessToken, newRefreshToken); + } + return token; + } + + private boolean isExpired(Instant instant) { + return instant != null && Instant.now().isAfter(instant); + } + + private Pair refreshEclipseToken(AuthToken token) { + if (token.refreshToken() == null || isExpired(token.refreshExpiresAt())) { + return null; + } + + var reg = Optional.ofNullable(clientRegistrationRepository).map(repo -> repo.findByRegistrationId("eclipse")) + .orElse(null); + if (reg == null) { + logger.error("Eclipse client not registered"); + return null; + } + + var tokenUri = reg.getProviderDetails().getTokenUri(); + + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + + var data = new LinkedMultiValueMap<>(); + data.add("grant_type", "refresh_token"); + data.add("client_id", reg.getClientId()); + data.add("client_secret", reg.getClientSecret()); + data.add("refresh_token", token.refreshToken()); + + try { + var request = new HttpEntity<>(data, headers); + var restTemplate = new RestTemplate(); + var response = restTemplate.postForObject(tokenUri, request, String.class); + var root = jsonMapper.readTree(response); + var newTokenValue = root.get("access_token").asString(); + var newRefreshTokenValue = root.get("refresh_token").asString(); + var expires_in = root.get("expires_in").asLong(); + + var issuedAt = Instant.now(); + var expiresAt = issuedAt.plusSeconds(expires_in); + + var newToken = new OAuth2AccessToken(TokenType.BEARER, newTokenValue, issuedAt, expiresAt); + var newRefreshToken = new OAuth2RefreshToken(newRefreshTokenValue, issuedAt); + return Pair.of(newToken, newRefreshToken); + } catch (HttpClientErrorException.BadRequest exc) { + // keycloak sends a 400 status response if the refresh call failed + logger.warn("Eclipse token could not be refreshed: {}", exc.getMessage()); + } catch (RestClientException exc) { + logger.error("Post request failed with URL: {}", tokenUri, exc); + } catch (JacksonException exc) { + logger.error("Invalid JSON data received from URL: {}", tokenUri, exc); + } + return null; + } +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreement.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreement.java new file mode 100644 index 000000000..cbb493ce3 --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreement.java @@ -0,0 +1,21 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import java.time.LocalDateTime; + +/** + * + * @param isActive + * @param documentId + * @param version Version of the last signed publisher agreement. + * @param timestamp Timestamp of the last signed publisher agreement. + */ +public record PublisherAgreement(boolean isActive, String documentId, String version, LocalDateTime timestamp) {} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreementAPI.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreementAPI.java new file mode 100644 index 000000000..4827413a7 --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreementAPI.java @@ -0,0 +1,61 @@ +/******************************************************************************** + * Copyright (c) 2026 Eclipse Foundation and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import org.eclipse.openvsx.UserService; +import org.eclipse.openvsx.json.UserJson; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.UrlUtil; + +import static org.eclipse.openvsx.util.UrlUtil.createApiUrl; + +@RestController +public class PublisherAgreementAPI { + + private final UserService users; + private final EclipseService eclipse; + + public PublisherAgreementAPI(UserService users, EclipseService eclipse) { + this.users = users; + this.eclipse = eclipse; + } + + @Operation(summary = "Sign the Eclipse Foundation publisher agreement on behalf of the logged-in user") + @PostMapping( + path = "/user/publisher-agreement", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity signPublisherAgreement() { + var user = users.findLoggedInUser(); + if (user == null) { + return new ResponseEntity<>(HttpStatus.FORBIDDEN); + } + try { + var agreement = eclipse.signPublisherAgreement(user); + var json = user.toUserJson(); + var serverUrl = UrlUtil.getBaseUrl(); + json.setRole(user.getRoleAsString()); + json.setTokensUrl(createApiUrl(serverUrl, "user", "tokens")); + json.setCreateTokenUrl(createApiUrl(serverUrl, "user", "token", "create")); + eclipse.enrichUserJson(json, user, agreement); + + return ResponseEntity.ok(json); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(UserJson.class); + } + } +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreementResponse.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreementResponse.java new file mode 100644 index 000000000..1425d097f --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherAgreementResponse.java @@ -0,0 +1,54 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * https://eclipsefdn.github.io/openvsx-publisher-agreement-specs/#/paths/~1publisher_agreement/post + */ +class PublisherAgreementResponse { + + /** Unique identifier for an addressable object in the API. */ + @JsonProperty("PersonID") + String personID; + + /** Unique identifier for an addressable object in the API. */ + @JsonProperty("DocumentID") + String documentID; + + /** The version number for the current document. */ + @JsonProperty("Version") + String version; + + /** Date string in the RFC 3339 format. */ + @JsonProperty("EffectiveDate") + String effectiveDate; + + /** Date string in the RFC 3339 format. */ + @JsonProperty("ReceivedDate") + String receivedDate; + + /** The signed document as a blob entity. */ + @JsonProperty("ScannedDocumentBLOB") + String scannedDocumentBLOB; + + /** The MIME type for the posted document blob. */ + @JsonProperty("ScannedDocumentMime") + String scannedDocumentMime; + + /** The name of the document being posted. */ + @JsonProperty("ScannedDocumentFileName") + String scannedDocumentFileName; + + /** Comment about the document being posted. */ + @JsonProperty("Comments") + String comments; +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherComplianceChecker.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherComplianceChecker.java new file mode 100644 index 000000000..e519aff97 --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/PublisherComplianceChecker.java @@ -0,0 +1,121 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import jakarta.persistence.EntityManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.transaction.support.TransactionTemplate; + +import org.eclipse.openvsx.ExtensionService; +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.PersonalAccessToken; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.NamingUtil; + +public class PublisherComplianceChecker { + + protected final Logger logger = LoggerFactory.getLogger(PublisherComplianceChecker.class); + + private final TransactionTemplate transactions; + private final EntityManager entityManager; + private final RepositoryService repositories; + private final ExtensionService extensions; + private final EclipseService eclipseService; + + @Value("${ovsx.eclipse.check-compliance-on-start:false}") + boolean checkCompliance; + + public PublisherComplianceChecker( + TransactionTemplate transactions, + EntityManager entityManager, + RepositoryService repositories, + ExtensionService extensions, + EclipseService eclipseService + ) { + this.transactions = transactions; + this.entityManager = entityManager; + this.repositories = repositories; + this.extensions = extensions; + this.eclipseService = eclipseService; + } + + @EventListener + public void checkPublishers(ApplicationStartedEvent event) { + if (!checkCompliance || !eclipseService.isActive()) { + return; + } + + var publisherTokens = repositories.findAllAccessTokens().stream() + .collect(Collectors.groupingBy(PersonalAccessToken::getUser)); + publisherTokens.keySet().forEach(user -> { + var accessTokens = publisherTokens.get(user); + if (!accessTokens.isEmpty() && !isCompliant(user)) { + // Found a non-compliant publisher: deactivate all extension versions + transactions.execute(status -> { + deactivateExtensions(accessTokens); + return null; + }); + } + }); + } + + private boolean isCompliant(UserData user) { + // Users without authentication provider have been created directly in the DB, + // so we skip the agreement check in this case. + if (user.getProvider() == null) { + return true; + } + if (user.getEclipsePersonId() == null) { + // The user has never logged in with Eclipse + return false; + } + + var profile = eclipseService.getPublicProfile(user.getEclipsePersonId()); + return Optional.of(profile) + .map(EclipseProfile::getPublisherAgreements) + .map(EclipseProfile.PublisherAgreements::getOpenVsx) + .map(EclipseProfile.PublisherAgreement::getVersion) + .isPresent(); + } + + private void deactivateExtensions(List accessTokens) { + var affectedExtensions = new LinkedHashSet(); + for (var accessToken : accessTokens) { + var versions = repositories.findVersionsByAccessToken(accessToken, true); + for (var version : versions) { + version.setActive(false); + entityManager.merge(version); + var extension = version.getExtension(); + affectedExtensions.add(extension); + logger.atInfo() + .setMessage("Deactivated: {} - {}") + .addArgument(() -> accessToken.getUser().getLoginName()) + .addArgument(() -> NamingUtil.toLogFormat(version)) + .log(); + } + } + + // Update affected extensions + for (var extension : affectedExtensions) { + extensions.updateExtension(extension); + entityManager.merge(extension); + } + } +} diff --git a/server/src/main/java/org/eclipsefdn/openvsx/eclipse/SignAgreementParam.java b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/SignAgreementParam.java new file mode 100644 index 000000000..c46a597f1 --- /dev/null +++ b/server/src/main/java/org/eclipsefdn/openvsx/eclipse/SignAgreementParam.java @@ -0,0 +1,54 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * https://eclipsefdn.github.io/openvsx-publisher-agreement-specs/#/paths/~1publisher_agreement/post + */ +public class SignAgreementParam { + + /** + * The version number of the document/agreement. + */ + private String version; + + /** + * The GitHub username of the user. This must match what the Eclipse Foundation has on file + * for the user to successfully sign the publisher agreement. + */ + @JsonProperty("github_handle") + private String githubHandle; + + public SignAgreementParam() { + } + + public SignAgreementParam(String version, String githubHandle) { + this.version = version; + this.githubHandle = githubHandle; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public String getGithubHandle() { + return githubHandle; + } + + public void setGithubHandle(String githubHandle) { + this.githubHandle = githubHandle; + } +} diff --git a/server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..13f80e2a7 --- /dev/null +++ b/server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.eclipsefdn.openvsx.eclipse.EclipseFoundationAutoConfiguration diff --git a/server/src/test/java/org/eclipsefdn/openvsx/eclipse/EclipseFoundationIntegrationTest.java b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/EclipseFoundationIntegrationTest.java new file mode 100644 index 000000000..594ef2433 --- /dev/null +++ b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/EclipseFoundationIntegrationTest.java @@ -0,0 +1,86 @@ +/******************************************************************************** + * Copyright (c) 2026 Eclipse Foundation and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpStatus; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import org.eclipse.openvsx.RegistryApplication; +import org.eclipse.openvsx.publish.PublisherAgreementService; +import org.eclipse.openvsx.security.OAuth2LoginHandler; +import org.eclipsefdn.openvsx.eclipse.support.AbstractRegistryIntegrationTest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Boots upstream's RegistryApplication with this module's auto-configuration on the + * classpath and verifies that the publisher agreement integration is wired in. + */ +@SpringBootTest(classes = RegistryApplication.class, webEnvironment = WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +class EclipseFoundationIntegrationTest extends AbstractRegistryIntegrationTest { + + @LocalServerPort + int port; + + @Autowired + TestRestTemplate restTemplate; + + @Autowired + ApplicationContext context; + + @Test + void upstreamEndpointsRespond() { + var response = restTemplate.getForEntity("http://localhost:" + port + "/user", String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("Not logged in."); + } + + @Test + void publisherAgreementEndpointIsMapped() { + assertThat(publisherAgreementMappings(context)).isPositive(); + } + + @Test + void publisherAgreementSwaggerGroupIsPublished() { + var swaggerConfig = restTemplate + .getForEntity("http://localhost:" + port + "/v3/api-docs/swagger-config", String.class); + assertThat(swaggerConfig.getBody()).contains("/v3/api-docs/publisher-agreement"); + + var groupDocs = restTemplate + .getForEntity("http://localhost:" + port + "/v3/api-docs/publisher-agreement", String.class); + assertThat(groupDocs.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(groupDocs.getBody()).contains("\"/user/publisher-agreement\""); + } + + @Test + void eclipseBeansAreRegistered() { + assertThat(context.getBean(PublisherAgreementService.class)).isInstanceOf(EclipseService.class); + assertThat(context.getBean(OAuth2LoginHandler.class)).isInstanceOf(EclipseLoginHandler.class); + assertThat(context.getBean(OAuth2LoginHandler.class).getRegistrationId()).isEqualTo("eclipse"); + assertThat(context.getBean(PublisherComplianceChecker.class)).isNotNull(); + } + + static long publisherAgreementMappings(ApplicationContext context) { + var mappings = context.getBean("requestMappingHandlerMapping", RequestMappingHandlerMapping.class); + return mappings.getHandlerMethods().keySet().stream() + .filter(info -> info.getPathPatternsCondition() != null + && info.getPathPatternsCondition().getPatternValues().contains("/user/publisher-agreement")) + .count(); + } +} diff --git a/server/src/test/java/org/eclipsefdn/openvsx/eclipse/EclipseServiceTest.java b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/EclipseServiceTest.java new file mode 100644 index 000000000..4d46f0617 --- /dev/null +++ b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/EclipseServiceTest.java @@ -0,0 +1,503 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import jakarta.persistence.EntityManager; +import org.jobrunr.scheduling.JobRequestScheduler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.util.Streamable; +import org.springframework.http.*; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import org.eclipse.openvsx.ExtensionService; +import org.eclipse.openvsx.ExtensionValidator; +import org.eclipsefdn.openvsx.eclipse.support.MockTransactionTemplate; +import org.eclipse.openvsx.UserService; +import org.eclipse.openvsx.adapter.VSCodeIdService; +import org.eclipse.openvsx.cache.CacheService; +import org.eclipse.openvsx.cache.LatestExtensionVersionCacheKeyGenerator; +import org.eclipse.openvsx.entities.*; +import org.eclipse.openvsx.metrics.ExtensionDownloadMetrics; +import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; +import org.eclipse.openvsx.publish.PublishingConfig; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.scanning.ExtensionScanPersistenceService; +import org.eclipse.openvsx.scanning.ExtensionScanService; +import org.eclipse.openvsx.search.SearchUtilService; +import org.eclipse.openvsx.storage.*; +import org.eclipse.openvsx.storage.log.DownloadCountService; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.LogService; +import org.eclipse.openvsx.util.TargetPlatform; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; + +@ExtendWith(SpringExtension.class) +@MockitoBean( + types = { + EntityManager.class, + SearchUtilService.class, + GoogleCloudStorageService.class, + AzureBlobStorageService.class, + AwsStorageService.class, + VSCodeIdService.class, + DownloadCountService.class, + ExtensionDownloadMetrics.class, + CacheService.class, + UserService.class, + PublishExtensionVersionHandler.class, + SimpleMeterRegistry.class, + FileCacheDurationConfig.class, + JobRequestScheduler.class, + CdnServiceConfig.class, + ExtensionScanService.class, + ExtensionScanPersistenceService.class, + LogService.class + } +) +class EclipseServiceTest { + + private static final String PUBLIC_PROFILE_URL = "https://test.openvsx.eclipse.org/account/profile/{personId}"; + private static final String PUBLISHER_AGREEMENT_URL = "https://test.openvsx.eclipse.org/openvsx/publisher_agreement/{personId}"; + + @MockitoBean + RepositoryService repositories; + + @MockitoBean + EclipseTokenService tokens; + + @MockitoBean + RestTemplate restTemplate; + + @Autowired + EclipseService eclipse; + + @BeforeEach + void setup() { + eclipse.publisherAgreementAllowedVersions = List.of("1", "1.0", "1.1"); + eclipse.publisherAgreementVersion = "1.1"; + eclipse.eclipseApiUrl = "https://test.openvsx.eclipse.org/"; + } + + @Test + void testGetPublicProfile() throws Exception { + Mockito.when( + restTemplate.exchange( + eq(PUBLIC_PROFILE_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(Map.of("personId", "test")))) + .thenReturn(mockProfileResponse()); + + var profile = eclipse.getPublicProfile("test"); + + assertThat(profile).isNotNull(); + assertThat(profile.getName()).isEqualTo("test"); + assertThat(profile.getGithubHandle()).isEqualTo("test"); + assertThat(profile.getPublisherAgreements()).isNotNull(); + assertThat(profile.getPublisherAgreements().getOpenVsx()).isNotNull(); + assertThat(profile.getPublisherAgreements().getOpenVsx().getVersion()).isEqualTo("1.1"); + } + + @Test + void testGetUserProfile() throws Exception { + Mockito.when(restTemplate.exchange(any(RequestEntity.class), eq(String.class))) + .thenReturn(mockProfileResponse()); + + var profile = eclipse.getUserProfile("12345"); + + assertThat(profile).isNotNull(); + + assertThat(profile.getName()).isEqualTo("test"); + assertThat(profile.getGithubHandle()).isEqualTo("test"); + assertThat(profile.getPublisherAgreements()).isNotNull(); + assertThat(profile.getPublisherAgreements().getOpenVsx()).isNotNull(); + assertThat(profile.getPublisherAgreements().getOpenVsx().getVersion()).isEqualTo("1.1"); + } + + @Test + void testGetPublisherAgreement() throws Exception { + var user = mockUser(); + user.setEclipsePersonId("test"); + + Mockito.when( + restTemplate.exchange( + eq(PUBLISHER_AGREEMENT_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(Map.of("personId", "test")))) + .thenReturn(mockAgreementResponse()); + + var agreement = eclipse.getPublisherAgreement(user); + assertThat(agreement).isNotNull(); + assertThat(agreement.isActive()).isTrue(); + assertThat(agreement.documentId()).isEqualTo("abcd"); + assertThat(agreement.version()).isEqualTo("1.1"); + assertThat(agreement.timestamp()).isEqualTo(LocalDateTime.of(2020, 10, 9, 5, 10, 32)); + } + + @Test + void testCheckPublisherOutdatedAgreement() throws Exception { + var user = mockUser(); + user.setEclipsePersonId("test"); + + Mockito.when( + restTemplate.exchange( + eq(PUBLISHER_AGREEMENT_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(Map.of("personId", "test")))) + .thenReturn(mockOutdatedAgreementResponse()); + + try { + eclipse.checkPublisherAgreement(user); + fail("Expected an ErrorResultException"); + } catch (ErrorResultException exc) { + assertThat(exc.getMessage()).isEqualTo( + "Your Publisher Agreement with the Eclipse Foundation is outdated (version 0.1). The current version is 1.1."); + } + } + + @Test + void testCheckPublisherOutdatedAgreementNoToken() throws Exception { + var user = mockUserNoToken(); + user.setEclipsePersonId("test"); + + Mockito.when( + restTemplate.exchange( + eq(PUBLIC_PROFILE_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(Map.of("personId", "test")))) + .thenReturn(mockOutdatedProfileResponse()); + + try { + eclipse.checkPublisherAgreement(user); + fail("Expected an ErrorResultException"); + } catch (ErrorResultException exc) { + assertThat(exc.getMessage()).isEqualTo( + "Your Publisher Agreement with the Eclipse Foundation is outdated (version 0.1). The current version is 1.1."); + } + } + + @Test + void testCheckPublisherAgreementAllowed() throws Exception { + var user = mockUser(); + user.setEclipsePersonId("test"); + + Mockito.when( + restTemplate.exchange( + eq(PUBLISHER_AGREEMENT_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(Map.of("personId", "test")))) + .thenReturn(mockAgreementResponse()); + + eclipse.checkPublisherAgreement(user); + } + + @Test + void testCheckPublisherAgreementAllowedNoToken() throws Exception { + var user = mockUserNoToken(); + user.setEclipsePersonId("test"); + + Mockito.when( + restTemplate.exchange( + eq(PUBLIC_PROFILE_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(Map.of("personId", "test")))) + .thenReturn(mockAllowedProfileResponse()); + + eclipse.checkPublisherAgreement(user); + } + + @Test + void testGetPublisherAgreementNotFound() throws Exception { + var user = mockUser(); + user.setEclipsePersonId("test"); + + var urlTemplate = "https://test.openvsx.eclipse.org/openvsx/publisher_agreement/{personId}"; + Mockito.when( + restTemplate.exchange( + eq(urlTemplate), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class), + eq(Map.of("personId", "test")))) + .thenThrow(new HttpClientErrorException(HttpStatus.NOT_FOUND)); + + var agreement = eclipse.getPublisherAgreement(user); + assertThat(agreement).isNull(); + } + + @Test + void testGetPublisherAgreementNotAuthenticated() throws Exception { + var user = mockUser(); + + var agreement = eclipse.getPublisherAgreement(user); + + assertThat(agreement).isNull(); + } + + @Test + void testSignPublisherAgreement() throws Exception { + var user = mockUser(); + Mockito.when(restTemplate.postForEntity(any(String.class), any(), eq(String.class))) + .thenReturn(mockAgreementResponse()); + Mockito.when(repositories.findVersionsByUser(user, false)) + .thenReturn(Streamable.empty()); + + var agreement = eclipse.signPublisherAgreement(user); + assertThat(agreement).isNotNull(); + assertThat(agreement.isActive()).isTrue(); + assertThat(agreement.documentId()).isEqualTo("abcd"); + assertThat(agreement.version()).isEqualTo("1.1"); + assertThat(agreement.timestamp()).isEqualTo(LocalDateTime.of(2020, 10, 9, 5, 10, 32)); + } + + @Test + void testSignPublisherAgreementReactivateExtension() throws Exception { + var user = mockUser(); + Mockito.when(restTemplate.postForEntity(any(String.class), any(), eq(String.class))) + .thenReturn(mockAgreementResponse()); + var namespace = new Namespace(); + namespace.setName("foo"); + var extension = new Extension(); + extension.setName("bar"); + extension.setNamespace(namespace); + var extVersion = new ExtensionVersion(); + extVersion.setVersion("1.0.0"); + extVersion.setTargetPlatform(TargetPlatform.NAME_UNIVERSAL); + extVersion.setExtension(extension); + extension.getVersions().add(extVersion); + Mockito.when(repositories.findVersionsByUser(user, false)) + .thenReturn(Streamable.of(extVersion)); + + var agreement = eclipse.signPublisherAgreement(user); + + assertThat(agreement).isNotNull(); + assertThat(agreement.isActive()).isTrue(); + assertThat(agreement.documentId()).isEqualTo("abcd"); + assertThat(agreement.version()).isEqualTo("1.1"); + assertThat(agreement.timestamp()).isEqualTo(LocalDateTime.of(2020, 10, 9, 5, 10, 32)); + assertThat(extVersion.isActive()).isTrue(); + assertThat(extension.isActive()).isTrue(); + } + + @Test + void testPublisherAgreementAlreadySigned() throws Exception { + var user = mockUser(); + Mockito.when(restTemplate.postForEntity(any(String.class), any(), eq(String.class))) + .thenThrow(new HttpClientErrorException(HttpStatus.CONFLICT)); + + try { + eclipse.signPublisherAgreement(user); + fail("Expected an ErrorResultException"); + } catch (ErrorResultException exc) { + assertThat(exc.getMessage()).isEqualTo("A publisher agreement is already present for user test."); + } + } + + @Test + void testRevokePublisherAgreement() { + var user = mockUser(); + user.setEclipsePersonId("test"); + + eclipse.revokePublisherAgreement(user, null); + } + + @Test + void testRevokePublisherAgreementByAdmin() { + var user = mockUser(); + user.setEclipsePersonId("test"); + + var admin = new UserData(); + admin.setLoginName("admin"); + admin.setEclipseToken(new AuthToken("67890", null, null, null, null, null)); + Mockito.when(tokens.getActiveEclipseToken(admin)) + .thenReturn(admin.getEclipseToken()); + + eclipse.revokePublisherAgreement(user, admin); + } + + private UserData mockUser() { + var user = new UserData(); + user.setLoginName("test"); + user.setProvider("github"); + user.setEclipseToken(new AuthToken("12345", null, null, null, null, null)); + Mockito.when(tokens.getActiveEclipseToken(user)) + .thenReturn(user.getEclipseToken()); + return user; + } + + private UserData mockUserNoToken() { + var user = new UserData(); + user.setLoginName("test"); + user.setProvider("github"); + Mockito.when(tokens.getActiveEclipseToken(user)) + .thenReturn(null); + return user; + } + + private ResponseEntity mockProfileResponse() throws IOException { + try (var stream = getClass().getResourceAsStream("profile-response.json")) { + assert stream != null; + var json = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new ResponseEntity<>(json, HttpStatus.OK); + } + } + + private ResponseEntity mockOutdatedProfileResponse() throws IOException { + try (var stream = getClass().getResourceAsStream("profile-outdated-response.json")) { + assert stream != null; + var json = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new ResponseEntity<>(json, HttpStatus.OK); + } + } + + private ResponseEntity mockAllowedProfileResponse() throws IOException { + try (var stream = getClass().getResourceAsStream("profile-allowed-response.json")) { + assert stream != null; + var json = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new ResponseEntity<>(json, HttpStatus.OK); + } + } + + private ResponseEntity mockAgreementResponse() throws IOException { + try (var stream = getClass().getResourceAsStream("publisher-agreement-response.json")) { + assert stream != null; + var json = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new ResponseEntity<>(json, HttpStatus.OK); + } + } + + private ResponseEntity mockOutdatedAgreementResponse() throws IOException { + try (var stream = getClass().getResourceAsStream("publisher-agreement-outdated-response.json")) { + assert stream != null; + var json = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new ResponseEntity<>(json, HttpStatus.OK); + } + } + + @TestConfiguration + static class TestConfig { + @Bean + TransactionTemplate transactionTemplate() { + return new MockTransactionTemplate(); + } + + @Bean + EclipseService eclipseService( + EclipseTokenService tokens, + ExtensionService extensions, + EntityManager entityManager, + RestTemplate restTemplate + ) { + return new EclipseService(tokens, extensions, entityManager, restTemplate); + } + + @Bean + ExtensionService extensionService( + EntityManager entityManager, + RepositoryService repositories, + SearchUtilService search, + CacheService cache, + LogService logs, + PublishExtensionVersionHandler publishHandler, + JobRequestScheduler scheduler, + ExtensionScanService extensionScanService, + ExtensionScanPersistenceService scanPersistenceService + ) { + return new ExtensionService( + new PublishingConfig(), + entityManager, + repositories, + search, + cache, + logs, + publishHandler, + scheduler, + extensionScanService, + scanPersistenceService); + } + + @Bean + ExtensionValidator extensionValidator() { + return new ExtensionValidator(); + } + + @Bean + StorageUtilService storageUtilService( + RepositoryService repositories, + GoogleCloudStorageService googleStorage, + AzureBlobStorageService azureStorage, + LocalStorageService localStorage, + AwsStorageService awsStorage, + DownloadCountService downloadCountService, + ExtensionDownloadMetrics downloadMetrics, + SearchUtilService search, + CacheService cache, + EntityManager entityManager, + FileCacheDurationConfig fileCacheDurationConfig, + CdnServiceConfig cdnServiceConfig + ) { + return new StorageUtilService( + repositories, + googleStorage, + azureStorage, + localStorage, + awsStorage, + downloadCountService, + downloadMetrics, + search, + cache, + entityManager, + fileCacheDurationConfig, + cdnServiceConfig); + } + + @Bean + LocalStorageService localStorageService() { + return new LocalStorageService(); + } + + @Bean + LatestExtensionVersionCacheKeyGenerator latestExtensionVersionCacheKeyGenerator() { + return new LatestExtensionVersionCacheKeyGenerator(); + } + } +} diff --git a/server/src/test/java/org/eclipsefdn/openvsx/eclipse/WithoutEclipseAutoConfigurationTest.java b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/WithoutEclipseAutoConfigurationTest.java new file mode 100644 index 000000000..4b2a238b4 --- /dev/null +++ b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/WithoutEclipseAutoConfigurationTest.java @@ -0,0 +1,66 @@ +/******************************************************************************** + * Copyright (c) 2026 Eclipse Foundation and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpStatus; + +import org.eclipse.openvsx.RegistryApplication; +import org.eclipse.openvsx.publish.PublisherAgreementService; +import org.eclipsefdn.openvsx.eclipse.support.AbstractRegistryIntegrationTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipsefdn.openvsx.eclipse.EclipseFoundationIntegrationTest.publisherAgreementMappings; + +/** + * Negative test: with the auto-configuration excluded, the application must boot + * and serve like a vanilla registry, with the publisher agreement absent. + */ +@SpringBootTest( + classes = RegistryApplication.class, + webEnvironment = WebEnvironment.RANDOM_PORT, + properties = "spring.autoconfigure.exclude=org.eclipsefdn.openvsx.eclipse.EclipseFoundationAutoConfiguration" +) +@AutoConfigureTestRestTemplate +class WithoutEclipseAutoConfigurationTest extends AbstractRegistryIntegrationTest { + + @LocalServerPort + int port; + + @Autowired + TestRestTemplate restTemplate; + + @Autowired + ApplicationContext context; + + @Test + void applicationIsHealthyWithoutAgreementSupport() { + var response = restTemplate.getForEntity("http://localhost:" + port + "/user", String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("Not logged in."); + } + + @Test + void publisherAgreementIsAbsent() { + assertThat(context.getBeanProvider(PublisherAgreementService.class).getIfAvailable()).isNull(); + assertThat(publisherAgreementMappings(context)).isZero(); + + var swaggerConfig = restTemplate + .getForEntity("http://localhost:" + port + "/v3/api-docs/swagger-config", String.class); + assertThat(swaggerConfig.getBody()).doesNotContain("publisher-agreement"); + } +} diff --git a/server/src/test/java/org/eclipsefdn/openvsx/eclipse/support/AbstractRegistryIntegrationTest.java b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/support/AbstractRegistryIntegrationTest.java new file mode 100644 index 000000000..2a9f9912a --- /dev/null +++ b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/support/AbstractRegistryIntegrationTest.java @@ -0,0 +1,37 @@ +/******************************************************************************** + * Copyright (c) 2026 Eclipse Foundation and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse.support; + +import org.junit.jupiter.api.Tag; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Base class for tests that boot the merged application. The PostgreSQL container + * is a JVM-wide singleton shared by every test context (same pattern as upstream's + * AbstractPostgresContainerTest). + */ +@Tag("integration") +public abstract class AbstractRegistryIntegrationTest { + + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16.2"); + + static { + POSTGRES.start(); + } + + @DynamicPropertySource + static void datasourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + } +} diff --git a/server/src/test/java/org/eclipsefdn/openvsx/eclipse/support/MockTransactionTemplate.java b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/support/MockTransactionTemplate.java new file mode 100644 index 000000000..3eae76ef7 --- /dev/null +++ b/server/src/test/java/org/eclipsefdn/openvsx/eclipse/support/MockTransactionTemplate.java @@ -0,0 +1,32 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipsefdn.openvsx.eclipse.support; + +import java.io.Serial; + +import org.springframework.transaction.TransactionException; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +public class MockTransactionTemplate extends TransactionTemplate { + + @Serial + private static final long serialVersionUID = 1L; + + @Override + public T execute(TransactionCallback action) throws TransactionException { + return action.doInTransaction(null); + } + + @Override + public void afterPropertiesSet() { + // Method override to prevent IllegalArgumentException from being thrown + } +} diff --git a/server/src/test/resources/application.yml b/server/src/test/resources/application.yml new file mode 100644 index 000000000..b15ec1656 --- /dev/null +++ b/server/src/test/resources/application.yml @@ -0,0 +1,50 @@ +spring: + jpa: + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + hibernate: + ddl-auto: none + session: + store-type: jdbc + jdbc: + initialize-schema: never + + security: + oauth2: + client: + registration: + github: + client-id: dummy-client-id + client-secret: dummy-client-secret + +bucket4j: + enabled: false + +jobrunr: + job-scheduler: + enabled: true + background-job-server: + enabled: false + worker-count: 1 + dashboard: + enabled: false + database: + type: sql + miscellaneous: + allow-anonymous-data-usage: false + +ovsx: + elasticsearch: + enabled: false + databasesearch: + enabled: true + storage: + local: + directory: /tmp + # same keys and values as the open-vsx.org deployment configuration + eclipse: + base-url: https://api.eclipse.org/ + publisher-agreement: + version: 1.1 + allowed-versions: "1,1.0,1.1" diff --git a/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-allowed-response.json b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-allowed-response.json new file mode 100644 index 000000000..42beb6416 --- /dev/null +++ b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-allowed-response.json @@ -0,0 +1,43 @@ +[ + { + "uid": "98765", + "name": "test", + "mail": null, + "picture": "http://my-profile-picture.com", + "eca": { + "signed": true, + "can_contribute_spec_project": true + }, + "publisher_agreements": { + "open-vsx": { + "version": "1" + } + }, + "is_committer": true, + "friends": { + "friend_id": null + }, + "first_name": "Foo", + "last_name": "Bar", + "full_name": "Foo Bar", + "github_handle": "test", + "twitter_handle": "test", + "org": "Test", + "job_title": "Software Engineer", + "website": "http://test.com", + "country": { + "code": null, + "name": null + }, + "bio": "Bla bla bla.", + "interests": [ + "Software Engineering" + ], + "working_groups_interests": [], + "forums_url": "https://api.eclipse.org/account/profile/test/forum", + "projects_url": "https://api.eclipse.org/account/profile/test/projects", + "gerrit_url": "https://api.eclipse.org/account/profile/test/gerrit", + "mailinglist_url": "https://api.eclipse.org/account/profile/test/mailing-list", + "mpc_favorites_url": "https://api.eclipse.org/marketplace/favorites/?name=test" + } +] diff --git a/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-outdated-response.json b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-outdated-response.json new file mode 100644 index 000000000..d43d1085d --- /dev/null +++ b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-outdated-response.json @@ -0,0 +1,43 @@ +[ + { + "uid": "98765", + "name": "test", + "mail": null, + "picture": "http://my-profile-picture.com", + "eca": { + "signed": true, + "can_contribute_spec_project": true + }, + "publisher_agreements": { + "open-vsx": { + "version": "0.1" + } + }, + "is_committer": true, + "friends": { + "friend_id": null + }, + "first_name": "Foo", + "last_name": "Bar", + "full_name": "Foo Bar", + "github_handle": "test", + "twitter_handle": "test", + "org": "Test", + "job_title": "Software Engineer", + "website": "http://test.com", + "country": { + "code": null, + "name": null + }, + "bio": "Bla bla bla.", + "interests": [ + "Software Engineering" + ], + "working_groups_interests": [], + "forums_url": "https://api.eclipse.org/account/profile/test/forum", + "projects_url": "https://api.eclipse.org/account/profile/test/projects", + "gerrit_url": "https://api.eclipse.org/account/profile/test/gerrit", + "mailinglist_url": "https://api.eclipse.org/account/profile/test/mailing-list", + "mpc_favorites_url": "https://api.eclipse.org/marketplace/favorites/?name=test" + } +] diff --git a/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-response.json b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-response.json new file mode 100644 index 000000000..b352a80db --- /dev/null +++ b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/profile-response.json @@ -0,0 +1,43 @@ +[ + { + "uid": "98765", + "name": "test", + "mail": null, + "picture": "http://my-profile-picture.com", + "eca": { + "signed": true, + "can_contribute_spec_project": true + }, + "publisher_agreements": { + "open-vsx": { + "version": "1.1" + } + }, + "is_committer": true, + "friends": { + "friend_id": null + }, + "first_name": "Foo", + "last_name": "Bar", + "full_name": "Foo Bar", + "github_handle": "test", + "twitter_handle": "test", + "org": "Test", + "job_title": "Software Engineer", + "website": "http://test.com", + "country": { + "code": null, + "name": null + }, + "bio": "Bla bla bla.", + "interests": [ + "Software Engineering" + ], + "working_groups_interests": [], + "forums_url": "https://api.eclipse.org/account/profile/test/forum", + "projects_url": "https://api.eclipse.org/account/profile/test/projects", + "gerrit_url": "https://api.eclipse.org/account/profile/test/gerrit", + "mailinglist_url": "https://api.eclipse.org/account/profile/test/mailing-list", + "mpc_favorites_url": "https://api.eclipse.org/marketplace/favorites/?name=test" + } +] diff --git a/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/publisher-agreement-outdated-response.json b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/publisher-agreement-outdated-response.json new file mode 100644 index 000000000..da7a90d34 --- /dev/null +++ b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/publisher-agreement-outdated-response.json @@ -0,0 +1,12 @@ +{ + "PersonID": "test", + "DocumentID": "abcd", + "Version": "0.1", + "EffectiveDate": "2020-10-09 05:10:32", + "ReceivedDate": "2020-10-09", + "ExpirationDate": null, + "ScannedDocumentBLOB": null, + "ScannedDocumentMime": "application/json", + "ScannedDocumentBytes": "117", + "ScannedDocumentFileName": "openvsx-publisher-agreement.json" +} diff --git a/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/publisher-agreement-response.json b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/publisher-agreement-response.json new file mode 100644 index 000000000..2579c6453 --- /dev/null +++ b/server/src/test/resources/org/eclipsefdn/openvsx/eclipse/publisher-agreement-response.json @@ -0,0 +1,12 @@ +{ + "PersonID": "test", + "DocumentID": "abcd", + "Version": "1.1", + "EffectiveDate": "2020-10-09 05:10:32", + "ReceivedDate": "2020-10-09", + "ExpirationDate": null, + "ScannedDocumentBLOB": null, + "ScannedDocumentMime": "application/json", + "ScannedDocumentBytes": "117", + "ScannedDocumentFileName": "openvsx-publisher-agreement.json" +} diff --git a/server/upstream b/server/upstream new file mode 160000 index 000000000..b0cc7f2da --- /dev/null +++ b/server/upstream @@ -0,0 +1 @@ +Subproject commit b0cc7f2da381843b58c7479e515f7167e74b4bf8