Reimplement apps and events endpoints from Ktor app in Spring Boot

Signed-off-by: William Brawner <me@wbrawner.com>
This commit is contained in:
William Brawner 2020-09-05 11:02:13 -07:00
parent 10a5bf9e7b
commit e6b7efa95f
99 changed files with 452 additions and 11 deletions

View file

@ -11,6 +11,7 @@ repositories {
}
dependencies {
implementation project(':shared')
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-quartz'
implementation 'org.springframework.boot:spring-boot-starter-security'

View file

@ -0,0 +1,59 @@
package com.wbrawner.flayre.server.apps;
import com.wbrawner.flayre.App;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/apps")
public class AppController {
private final AppRepository appRepository;
@Autowired
public AppController(AppRepository appRepository) {
this.appRepository = appRepository;
}
@GetMapping
public ResponseEntity<List<App>> getApps() {
return ResponseEntity.ok(appRepository.getApps());
}
@GetMapping("/{id}")
public ResponseEntity<App> getApp(@PathVariable String id) {
App app;
if ((app = appRepository.getApp(id)) != null) {
return ResponseEntity.ok(app);
}
return ResponseEntity.notFound().build();
}
@PostMapping
public ResponseEntity<App> createApp(@RequestBody String name) {
App app = new App(name);
if (appRepository.saveApp(app)) {
return ResponseEntity.ok(app);
}
return ResponseEntity.notFound().build();
}
@PatchMapping("/{id}")
public ResponseEntity<Void> updateApp(@PathVariable String id, @RequestBody String name) {
if (appRepository.updateApp(id, name)) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteApp(@PathVariable String id) {
if (appRepository.deleteApp(id)) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}
}

View file

@ -0,0 +1,79 @@
package com.wbrawner.flayre.server.apps;
import com.wbrawner.flayre.App;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class AppRepository {
private final JdbcTemplate jdbcTemplate;
@Autowired
public AppRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
this.jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS apps (\n" +
" id VARCHAR(32) PRIMARY KEY,\n" +
" name VARCHAR(256) UNIQUE NOT NULL\n" +
")");
}
@NonNull
public List<App> getApps() {
return jdbcTemplate.query("SELECT * from apps", (rs, rowNum) -> new App(
rs.getString(rs.findColumn("id")),
rs.getString(rs.findColumn("name"))
));
}
@Nullable
public App getApp(String appId) {
var results = jdbcTemplate.query(
"SELECT * from apps WHERE id = ?",
new Object[]{appId},
(rs, rowNum) -> new App(
rs.getString(rs.findColumn("id")),
rs.getString(rs.findColumn("name"))
)
);
if (results.size() == 1) {
return results.get(0);
}
return null;
}
public boolean saveApp(App app) {
return jdbcTemplate.update(
"INSERT INTO apps (id, name) VALUES (?, ?)",
app.getId(),
app.getName()
) == 1;
}
/**
* Update the name of a given app.
*
* @param appId the id of the app to update
* @param name the new name for the app
* @return true on success or false on failure
*/
public boolean updateApp(String appId, String name) {
return jdbcTemplate.update("UPDATE apps SET name = ? WHERE id = ?", name, appId) == 1;
}
/**
* Delete an app by its id.
*
* @param appId the id of the app to delete
* @return true if the app was deleted or false if not
*/
public boolean deleteApp(String appId) {
return jdbcTemplate.update("DELETE FROM apps WHERE id = ?", appId) == 1;
}
}

View file

@ -0,0 +1,68 @@
package com.wbrawner.flayre.server.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.cors.CorsConfiguration;
import java.util.Arrays;
import java.util.Collections;
@Configuration
@EnableWebSecurity
public class SecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
private final Environment environment;
public SecurityConfigurerAdapter(Environment environment) {
this.environment = environment;
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser(environment.getProperty("flayre.admin.user")).password(passwordEncoder().encode(environment.getProperty("flayre.admin.password")))
.authorities("ROLE_USER");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers(HttpMethod.GET, "/flayre.js")
.permitAll()
.antMatchers(HttpMethod.POST, "/api/events")
.permitAll()
.and()
.authorizeRequests()
.anyRequest()
.authenticated()
.and()
.httpBasic()
.and()
.cors()
.configurationSource(request -> {
var corsConfig = new CorsConfiguration();
corsConfig.applyPermitDefaultValues();
corsConfig.setAllowedOrigins(Collections.singletonList("*"));
corsConfig.setAllowedMethods(Arrays.asList(HttpMethod.POST.name(), HttpMethod.OPTIONS.name()));
corsConfig.setAllowCredentials(true);
return corsConfig;
})
.and()
.csrf()
.disable();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View file

@ -0,0 +1,50 @@
package com.wbrawner.flayre.server.events;
import com.wbrawner.flayre.Event;
import com.wbrawner.flayre.EventRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/events")
public class EventController {
private final EventRepository eventRepository;
@Autowired
public EventController(EventRepository eventRepository) {
this.eventRepository = eventRepository;
}
@GetMapping
public ResponseEntity<List<Event>> getEvents() {
return ResponseEntity.ok(eventRepository.getEvents());
}
@PostMapping
public ResponseEntity<Event> createEvent(
@RequestHeader("User-Agent") String userAgent,
@RequestBody EventRequest eventRequest
) {
Event event = new Event(
eventRequest.appId,
eventRequest.date,
eventRequest.type,
userAgent,
eventRequest.platform,
eventRequest.manufacturer,
eventRequest.model,
eventRequest.version,
eventRequest.locale,
eventRequest.sessionId,
eventRequest.data
);
if (eventRepository.saveEvent(event)) {
return ResponseEntity.ok(event);
}
return ResponseEntity.notFound().build();
}
}

View file

@ -0,0 +1,99 @@
package com.wbrawner.flayre.server.events;
import com.wbrawner.flayre.Event;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.List;
@Component
public class EventRepository {
private final JdbcTemplate jdbcTemplate;
@Autowired
public EventRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
this.jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS events (\n" +
" id VARCHAR(32) PRIMARY KEY,\n" +
" app_id VARCHAR(32) NOT NULL,\n" +
" date DATETIME NOT NULL,\n" +
" user_agent VARCHAR(256),\n" +
" platform VARCHAR(32),\n" +
" manufacturer VARCHAR(256),\n" +
" model VARCHAR(256),\n" +
" version VARCHAR(32),\n" +
" locale VARCHAR(8),\n" +
" session_id VARCHAR(32),\n" +
" data TEXT DEFAULT NULL,\n" +
" type VARCHAR(256) DEFAULT NULL,\n" +
" FOREIGN KEY (app_id)\n" +
" REFERENCES apps(id)\n" +
" ON DELETE CASCADE\n" +
")");
}
@NonNull
public List<Event> getEvents() {
return jdbcTemplate.query("SELECT * from events", (rs, rowNum) -> Event.fromResultSet(rs));
}
@Nullable
public Event getEvent(String eventId) {
var results = jdbcTemplate.query(
"SELECT * from events WHERE id = ?",
new Object[]{eventId},
(rs, rowNum) -> Event.fromResultSet(rs)
);
if (results.size() == 1) {
return results.get(0);
}
return null;
}
public boolean saveEvent(Event event) {
return jdbcTemplate.update(
"INSERT INTO events (" +
"id, " +
"app_id, " +
"date, " +
"user_agent, " +
"platform, " +
"manufacturer, " +
"model, " +
"version, " +
"locale, " +
"session_id, " +
"data, " +
"type" +
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
event.getId(),
event.getAppId(),
event.getDate(),
event.getUserAgent(),
event.getPlatform(),
event.getManufacturer(),
event.getModel(),
event.getVersion(),
event.getLocale(),
event.getSessionId(),
event.getData(),
event.getType().name()
) == 1;
}
/**
* Delete all events before a given date.
*
* @param before the date before which all events will be deleted
* @return the number of events that were deleted
*/
public int deleteEvents(Date before) {
return jdbcTemplate.update("DELETE FROM events WHERE date <= ?", before);
}
}

View file

@ -1 +1,9 @@
spring.datasource.url=jdbc:mysql://localhost:3306/flayre
spring.datasource.username=flayre
spring.datasource.password=flayre
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.testWhileIdle=true
spring.datasource.timeBetweenEvictionRunsMillis=60000
spring.datasource.validationQuery=SELECT 1
flayre.admin.user=admin
flayre.admin.password=flayre

View file

@ -0,0 +1,25 @@
(function () {
if (window.navigator.doNotTrack === '1') {
console.log('Flayre respects DNT');
return;
}
const flayreDomain = document.currentScript.src.split('/').slice(0, 3).join('/');
const app = document.currentScript.dataset.app;
fetch(`${flayreDomain}/api/events`, {
method: 'POST',
headers: {
'Content-type': 'application/json',
},
body: JSON.stringify({
appId: app,
date: new Date().toISOString(),
platform: window.navigator.platform,
locale: window.navigator.language,
data: window.location.pathname,
type: 'VIEW'
})
}).catch((err) => {
console.error(err)
})
})();

View file

@ -1 +0,0 @@
/home/wbrawner/Projects/flayre-android/shared/build/classes/kotlin/main/com/wbrawner/flayre/App.class:/home/wbrawner/Projects/flayre-android/shared/build/classes/kotlin/main/com/wbrawner/flayre/Event$InteractionType.class:/home/wbrawner/Projects/flayre-android/shared/build/classes/kotlin/main/com/wbrawner/flayre/Event.class:/home/wbrawner/Projects/flayre-android/shared/build/classes/kotlin/main/com/wbrawner/flayre/UtilsKt.class

Binary file not shown.

View file

@ -1,2 +0,0 @@
Manifest-Version: 1.0

View file

@ -2,15 +2,15 @@ package com.wbrawner.flayre;
import static com.wbrawner.flayre.Utils.randomId;
class App {
public class App {
private final String id;
private final String name;
App(String name) {
public App(String name) {
this(randomId(32), name);
}
App(String id, String name) {
public App(String id, String name) {
this.id = id;
this.name = name;
}

View file

@ -1,10 +1,12 @@
package com.wbrawner.flayre;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Date;
import static com.wbrawner.flayre.Utils.randomId;
class Event {
public class Event {
private final String id;
private final String appId;
private final Date date;
@ -119,11 +121,28 @@ class Event {
return data;
}
enum InteractionType {
public enum InteractionType {
VIEW,
CLICK,
ERROR,
CRASH
}
public static Event fromResultSet(ResultSet rs) throws SQLException {
return new Event(
rs.getString(rs.findColumn("id")),
rs.getString(rs.findColumn("name")),
rs.getDate(rs.findColumn("date")),
Event.InteractionType.valueOf(rs.getString(rs.findColumn("type"))),
rs.getString(rs.findColumn("user_agent")),
rs.getString(rs.findColumn("platform")),
rs.getString(rs.findColumn("manufacturer")),
rs.getString(rs.findColumn("model")),
rs.getString(rs.findColumn("version")),
rs.getString(rs.findColumn("locale")),
rs.getString(rs.findColumn("session_id")),
rs.getString(rs.findColumn("data"))
);
}
}

View file

@ -0,0 +1,38 @@
package com.wbrawner.flayre;
import java.util.Date;
public class EventRequest {
public final String appId;
public final Date date;
public final Event.InteractionType type;
public final String platform;
public final String manufacturer;
public final String model;
public final String version;
public final String locale;
public final String sessionId;
public final String data;
public EventRequest(String appId,
Date date,
Event.InteractionType type,
String platform,
String manufacturer,
String model,
String version,
String locale,
String sessionId,
String data) {
this.appId = appId;
this.date = date;
this.type = type;
this.platform = platform;
this.manufacturer = manufacturer;
this.model = model;
this.version = version;
this.locale = locale;
this.sessionId = sessionId;
this.data = data;
}
}