소스 검색

fix paymentsEnable check, add Vagrantfile

tags/v1.5.4
Jonathan Cobb 4 년 전
부모
커밋
ed7c726271
21개의 변경된 파일88개의 추가작업 그리고 58개의 파일을 삭제
  1. +1
    -0
      .gitignore
  2. +31
    -19
      Vagrantfile
  3. +1
    -1
      bin/bdocker
  4. +1
    -1
      bin/build_dist
  5. +13
    -0
      bin/first_time_setup.sh
  6. +1
    -2
      bin/prep_bubble_jar
  7. +1
    -1
      bubble-server/src/main/java/bubble/cloud/payment/delegate/DelegatedPaymentDriver.java
  8. +3
    -3
      bubble-server/src/main/java/bubble/dao/bill/AccountPlanDAO.java
  9. +1
    -1
      bubble-server/src/main/java/bubble/dao/cloud/CloudServiceDAO.java
  10. +2
    -2
      bubble-server/src/main/java/bubble/resources/account/AuthResource.java
  11. +4
    -4
      bubble-server/src/main/java/bubble/resources/bill/AccountPlansResource.java
  12. +8
    -10
      bubble-server/src/main/java/bubble/server/BubbleConfiguration.java
  13. +3
    -0
      bubble-server/src/main/java/bubble/service/boot/SelfNodeService.java
  14. +1
    -1
      bubble-server/src/main/java/bubble/service/boot/StandardSelfNodeService.java
  15. +9
    -7
      bubble-server/src/main/java/bubble/service/cloud/AnsiblePrepService.java
  16. +2
    -2
      bubble-server/src/main/java/bubble/service/cloud/StandardNetworkService.java
  17. +1
    -1
      bubble-server/src/main/java/bubble/service/dbfilter/FilteredEntityIterator.java
  18. +1
    -1
      bubble-server/src/main/java/bubble/service/dbfilter/FullEntityIterator.java
  19. +2
    -0
      bubble-server/src/main/java/bubble/service_dbfilter/DbFilterSelfNodeService.java
  20. +1
    -1
      bubble-web
  21. +1
    -1
      utils/cobbzilla-utils

+ 1
- 0
.gitignore 파일 보기

@@ -16,3 +16,4 @@ configs
dist
.m2
.venv
.vagrant

+ 31
- 19
Vagrantfile 파일 보기

@@ -2,10 +2,11 @@
# vi: set ft=ruby :
# Copyright (c) 2020 Bubble, Inc. All rights reserved. For personal (non-commercial) use, see license: https://getbubblenow.com/bubble-license/
#
# ==================
# Bubble Vagrantfile
# ==================
# `vagrant up` will create a full Bubble development environment, and optionally start
# a local launcher.
# Running `vagrant up` will create a full Bubble development environment, and
# optionally start a local launcher.
#
# ## Environment Variables
#
@@ -20,10 +21,10 @@
# Set the `BUBBLE_PORT` environment variable to another port, and Bubble will listen on
# that port instead.
#
# ### BUBBLE_GIT_TAG
# ### BUBBLE_TAG
# By default, the Vagrant box will run the bleeding edge (`master` branch) of Bubble.
# Set the `BUBBLE_GIT_TAG` environment variable to a git branch or tag that should be
# checked out instead.
# Set the `BUBBLE_TAG` environment variable to the name of a git branch or tag to check
# out instead.
#
#
Vagrant.configure("2") do |config|
@@ -36,27 +37,38 @@ Vagrant.configure("2") do |config|
# Anyone who can reach port 8090 on this system will be able to access the launcher
# config.vm.network "forwarded_port", guest: 8090, host: ENV['BUBBLE_PORT'] || 8090

config.vm.provision :shell do |s|
s.inline = <<-SHELL
apt-get update -y
apt-get upgrade -y
SHELL
end
config.vm.provision :shell do |s|
s.env = {
LETSENCRYPT_EMAIL: ENV['LETSENCRYPT_EMAIL'],
GIT_TAG: ENV['BUBBLE_GIT_TAG'] || 'master'
BUBBLE_TAG: ENV['BUBBLE_TAG'] || 'master'
}
s.privileged = false
s.inline = <<-SHELL
apt-get update -y
apt-get upgrade -y
if [[ ! -d bubble ]] ; then
git clone https://git.bubblev.org/bubblev/bubble.git
fi
cd bubble
git fetch && git pull origin ${GIT_TAG}
# Get the code
git clone https://git.bubblev.org/bubblev/bubble.git
cd bubble && git fetch && git pull origin ${BUBBLE_TAG}

# Initialize the system
./bin/first_time_ubuntu.sh
./bin/first_time_setup.sh
if [[ -n "${LETSENCRYPT_EMAIL}" ]] ; then
echo "export LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL}" > bubble.env
./bin/run.sh bubble.env

# Clone and build all dependencies
SKIP_BUBBLE_BUILD=1 ./bin/first_time_setup.sh

# Build the bubble jar
MVN_QUIET=""
BUBBLE_PRODUCTION=1 mvn ${MVN_QUIET} -Pproduction clean package

# Start the Local Launcher if LETSENCRYPT_EMAIL is defined
if [[ -n \"${LETSENCRYPT_EMAIL}\" ]] ; then
echo \"export LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL}\" > bubble.env
# ./bin/run.sh bubble.env
fi
echo "we are in $(pwd) ok man??"
# chown -R vagrant ./*
SHELL
end
end

+ 1
- 1
bin/bdocker 파일 보기

@@ -57,7 +57,7 @@ BUBBLE_SLIM_TAG="${DOCKER_REPO}/slim-launcher:${VERSION}"
BUBBLE_ENV="${HOME}/.bubble.env"

if [[ "${MODE}" == "build" ]] ; then
if [[ $(find bubble-server/target -type f -name "bubble-server-*.jar" | wc -l | tr -d ' ') -eq 0 ]] ; then
if [[ $(find bubble-server/target -type f -name "bubble-server-*-prod.jar" | wc -l | tr -d ' ') -eq 0 ]] ; then
die "No bubble jar found in $(pwd)/bubble-server/target"
fi
docker build --no-cache -t "${BUBBLE_TAG}" . || die "Error building docker image"


+ 1
- 1
bin/build_dist 파일 보기

@@ -39,7 +39,7 @@ FULL_JAR="$(find "${JAR_DIR}" -type f -name "bubble-server-*-full.jar" | head -1
if [[ -z "${FULL_JAR}" ]] ; then
die "No full bubble jar found in ${JAR_DIR}"
fi
JAR="$(find "${JAR_DIR}" -type f -name "bubble-server-*.jar" | grep -v full | head -1)"
JAR="$(find "${JAR_DIR}" -type f -name "bubble-server-*-prod.jar" | head -1)"
if [[ -z "${JAR}" ]] ; then
die "No regular bubble jar found in ${JAR_DIR}"
fi


+ 13
- 0
bin/first_time_setup.sh 파일 보기

@@ -18,6 +18,15 @@
# If you prefer to checkout git submodules using SSH instead of HTTPS, set the BUBBLE_SSH_SUBMODULES
# environment variable to 'true'
#
# Environment Variables
#
# SKIP_BUBBLE_BUILD : if set to 1, then everything will be built except for the bubble jar
#
# BUBBLE_SETUP_MODE : only used if SKIP_BUBBLE_BUILD is not set. values are:
# debug - build the bubble jar without including the website
# web - build the bubble jar, including the website
# production - build the bubble jar and the bubble full jar, both including the website
#

function die() {
if [[ -z "${SCRIPT}" ]] ; then
@@ -55,6 +64,10 @@ for repo in ${UTIL_REPOS}; do
done
popd || die "Error popping back from utils directory"

if [[ -n "${SKIP_BUBBLE_BUILD}" && "${SKIP_BUBBLE_BUILD}" == "1" ]] ; then
exit 0
fi

if [[ -z "${BUBBLE_SETUP_MODE}" || "${BUBBLE_SETUP_MODE}" == "web" ]] ; then
INSTALL_WEB=web mvn ${MVN_QUIET} -Pproduction clean package || die "Error building bubble jar"



+ 1
- 2
bin/prep_bubble_jar 파일 보기

@@ -50,8 +50,7 @@ if [[ -n "${BUBBLE_PRODUCTION}" || ( -n "${INSTALL_WEB}" && "${INSTALL_WEB}" ==
else
WEBPACK_OPTIONS=""
fi
# Try webpack twice
cd "${BUBBLE_WEB}" && npm install && rm -rf dist/ && webpack "${WEBPACK_OPTIONS}" || webpack "${WEBPACK_OPTIONS}" || die "Error building bubble-web"
cd "${BUBBLE_WEB}" && npm install && rm -rf dist/ && webpack "${WEBPACK_OPTIONS}" || die "Error building bubble-web"
cp -R "${BUBBLE_WEB}/dist"/* "${CLASSES_DIR}/site"/ || die "Error copying ${BUBBLE_WEB}/dist/* -> ${CLASSES_DIR}/site/"
cd "${CLASSES_DIR}" && zip -u -r "${BUBBLE_JAR}" site || die "Error updating ${BUBBLE_JAR} with site"
echo "Installed bubble-web to ${CLASSES_DIR}/site/"


+ 1
- 1
bubble-server/src/main/java/bubble/cloud/payment/delegate/DelegatedPaymentDriver.java 파일 보기

@@ -30,7 +30,7 @@ public class DelegatedPaymentDriver extends DelegatedCloudServiceDriverBase impl
log.warn("getPaymentMethodType: delegated driver has non-delegated cloud: "+cloud.getUuid());
return cloud.getPaymentDriver(configuration).getPaymentMethodType();
}
if (!configuration.paymentsEnabled()) {
if (!configuration.getPaymentsEnabled()) {
log.warn("getPaymentMethodType: payments not enabled, returning null");
return null;
};


+ 3
- 3
bubble-server/src/main/java/bubble/dao/bill/AccountPlanDAO.java 파일 보기

@@ -115,7 +115,7 @@ public class AccountPlanDAO extends AccountOwnedEntityDAO<AccountPlan> {
if (errors.isInvalid()) throw invalidEx(errors);
if (errors.hasSuggestedName()) accountPlan.setName(errors.getSuggestedName());

if (configuration.paymentsEnabled()) {
if (configuration.getPaymentsEnabled()) {
if (!accountPlan.hasPaymentMethodObject()) throw invalidEx("err.paymentMethod.required");
if (!accountPlan.getPaymentMethodObject().hasUuid()) throw invalidEx("err.paymentMethod.required");

@@ -149,7 +149,7 @@ public class AccountPlanDAO extends AccountOwnedEntityDAO<AccountPlan> {
}

@Override public AccountPlan postCreate(AccountPlan accountPlan, Object context) {
if (configuration.paymentsEnabled()) {
if (configuration.getPaymentsEnabled()) {
final String accountPlanUuid = accountPlan.getUuid();
final String paymentMethodUuid = accountPlan.getPaymentMethodObject().getUuid();
final BubblePlan plan = planDAO.findByUuid(accountPlan.getPlan());
@@ -191,7 +191,7 @@ public class AccountPlanDAO extends AccountOwnedEntityDAO<AccountPlan> {
networkDAO.delete(accountPlan.getDeletedNetwork());
} else {
networkDAO.delete(accountPlan.getNetwork());
if (configuration.paymentsEnabled()) {
if (configuration.getPaymentsEnabled()) {
refundService.processRefunds();
}
}


+ 1
- 1
bubble-server/src/main/java/bubble/dao/cloud/CloudServiceDAO.java 파일 보기

@@ -102,7 +102,7 @@ public class CloudServiceDAO extends AccountOwnedTemplateDAO<CloudService> {
if (cloud.getType() == CloudServiceType.payment
&& cloud.template()
&& cloud.enabled()
&& !configuration.paymentsEnabled()) {
&& !configuration.getPaymentsEnabled()) {
// a public template for a payment cloud has been added, and payments were not enabled -- now they are
configuration.refreshPublicSystemConfigs();
}


+ 2
- 2
bubble-server/src/main/java/bubble/resources/account/AuthResource.java 파일 보기

@@ -367,7 +367,7 @@ public class AuthResource {
}

String currency = null;
if (configuration.paymentsEnabled()) {
if (configuration.getPaymentsEnabled()) {
currency = currencyForLocale(request.getLocale(), getDEFAULT_LOCALE());
// do we have any plans with this currency?
if (!planDAO.getSupportedCurrencies().contains(currency)) {
@@ -393,7 +393,7 @@ public class AuthResource {

final Account account = accountDAO.newAccount(req, null, request, parent);
SimpleViolationException promoEx = null;
if (configuration.paymentsEnabled()) {
if (configuration.getPaymentsEnabled()) {
if (request.hasPaymentMethod()) {
final AccountPaymentMethod paymentMethodObject = request.getPaymentMethodObject();
log.info("register: found AccountPaymentMethod at registration-time: " + json(paymentMethodObject, COMPACT_MAPPER));


+ 4
- 4
bubble-server/src/main/java/bubble/resources/bill/AccountPlansResource.java 파일 보기

@@ -109,12 +109,12 @@ public class AccountPlansResource extends AccountOwnedResource<AccountPlan, Acco
}

@Override protected Object daoCreate(AccountPlan toCreate) {
if (!configuration.paymentsEnabled()) toCreate.setEnabled(true);
if (!configuration.getPaymentsEnabled()) toCreate.setEnabled(true);
return super.daoCreate(toCreate);
}

@Override protected Object daoUpdate(AccountPlan toUpdate) {
if (!configuration.paymentsEnabled()) toUpdate.setEnabled(true);
if (!configuration.getPaymentsEnabled()) toUpdate.setEnabled(true);
return super.daoUpdate(toUpdate);
}

@@ -258,7 +258,7 @@ public class AccountPlansResource extends AccountOwnedResource<AccountPlan, Acco
}

AccountPaymentMethod paymentMethod = null;
if (configuration.paymentsEnabled()) {
if (configuration.getPaymentsEnabled()) {
if (!request.hasPaymentMethodObject()) {
final List<AccountPaymentMethod> paymentMethods = paymentMethodDAO.findByAccountAndNotPromoAndNotDeleted(caller.getUuid());
if (empty(paymentMethods)) {
@@ -296,7 +296,7 @@ public class AccountPlansResource extends AccountOwnedResource<AccountPlan, Acco
request.setNetwork(newNetwork.getUuid());
}

if (configuration.paymentsEnabled()) {
if (configuration.getPaymentsEnabled()) {
if (paymentMethod != null && !paymentMethod.hasUuid()) {
final AccountPaymentMethod paymentMethodToCreate = new AccountPaymentMethod(request.getPaymentMethodObject()).setAccount(request.getAccount());
final AccountPaymentMethod paymentMethodCreated = paymentMethodDAO.create(paymentMethodToCreate);


+ 8
- 10
bubble-server/src/main/java/bubble/server/BubbleConfiguration.java 파일 보기

@@ -24,7 +24,7 @@ import bubble.model.device.DeviceSecurityLevel;
import bubble.server.listener.BubbleFirstTimeListener;
import bubble.service.backup.RestoreService;
import bubble.service.boot.ActivationService;
import bubble.service.boot.StandardSelfNodeService;
import bubble.service.boot.SelfNodeService;
import bubble.service.notify.LocalNotificationStrategy;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.github.jknack.handlebars.Handlebars;
@@ -145,7 +145,7 @@ public class BubbleConfiguration extends PgRestServerConfiguration
}

@JsonIgnore @Transient public synchronized BubbleNode getThisNode () {
return getBean(StandardSelfNodeService.class).getThisNode();
return getBean(SelfNodeService.class).getThisNode();
}
@JsonIgnore @Transient public boolean isSelfSage () {
final BubbleNode selfNode = getThisNode();
@@ -162,7 +162,7 @@ public class BubbleConfiguration extends PgRestServerConfiguration
}

@JsonIgnore @Transient public synchronized BubbleNetwork getThisNetwork () {
return getBean(StandardSelfNodeService.class).getThisNetwork();
return getBean(SelfNodeService.class).getThisNetwork();
}

@JsonIgnore @Transient public boolean isRootNetwork () {
@@ -171,7 +171,7 @@ public class BubbleConfiguration extends PgRestServerConfiguration
}

@JsonIgnore @Transient public synchronized BubbleNode getSageNode () {
return getBean(StandardSelfNodeService.class).getSageNode();
return getBean(SelfNodeService.class).getSageNode();
}
public boolean hasSageNode () { return getSageNode() != null; }

@@ -378,7 +378,7 @@ public class BubbleConfiguration extends PgRestServerConfiguration
{TAG_NETWORK_UUID, thisNetwork == null ? null : thisNetwork.getUuid()},
{TAG_SAGE_LAUNCHER, thisNetwork == null || isSageLauncher()},
{TAG_BUBBLE_NODE, isSageLauncher() || thisNetwork == null ? null : thisNetwork.node()},
{TAG_PAYMENTS_ENABLED, cloudDAO.paymentsEnabled()},
{TAG_PAYMENTS_ENABLED, getPaymentsEnabled()},
{TAG_LOCAL_NETWORK, thisNetwork == null || thisNetwork.local()},
{TAG_PROMO_CODE_POLICY, getPromoCodePolicy().name()},
{TAG_REQUIRE_SEND_METRICS, requireSendMetrics()},
@@ -416,13 +416,11 @@ public class BubbleConfiguration extends PgRestServerConfiguration
background(this::getPublicSystemConfigs, "BubbleConfiguration.refreshPublicSystemConfigs");
}

public boolean paymentsEnabled () {
final Object peValue = getPublicSystemConfigs().get(TAG_PAYMENTS_ENABLED);
return peValue != null && Boolean.parseBoolean(peValue.toString());
}
@Getter(lazy=true) private final Boolean paymentsEnabled = initPaymentsEnabled();
private boolean initPaymentsEnabled () { return getBean(CloudServiceDAO.class).paymentsEnabled(); }

public void requiresPaymentsEnabled () {
if (!paymentsEnabled()) throw invalidEx("err_noPaymentMethods");
if (!getPaymentsEnabled()) throw invalidEx("err_noPaymentMethods");
}

@Getter @Setter private Boolean requireSendMetrics;


+ 3
- 0
bubble-server/src/main/java/bubble/service/boot/SelfNodeService.java 파일 보기

@@ -15,6 +15,8 @@ public interface SelfNodeService {

boolean initThisNode(BubbleNode thisNode);

BubbleNode getSageNode();

BubbleNode getThisNode ();

BubbleNetwork getThisNetwork();
@@ -36,4 +38,5 @@ public interface SelfNodeService {
*/
@NonNull Optional<Long> getLogFlagExpirationTime();
void setLogFlag(final boolean logFlag, @NonNull final Optional<Integer> ttlInSeconds);

}

+ 1
- 1
bubble-server/src/main/java/bubble/service/boot/StandardSelfNodeService.java 파일 보기

@@ -181,7 +181,7 @@ public class StandardSelfNodeService implements SelfNodeService {
}

// start RefundService if payments are enabled and this is a SageLauncher
if (c.paymentsEnabled() && c.isSageLauncher() && thisNode.sage()) {
if (c.getPaymentsEnabled() && c.isSageLauncher() && thisNode.sage()) {
log.info("onStart: starting BillingService and RefundService");
c.getBean(BillingService.class).start();
c.getBean(StandardRefundService.class).start();


+ 9
- 7
bubble-server/src/main/java/bubble/service/cloud/AnsiblePrepService.java 파일 보기

@@ -50,7 +50,9 @@ import static org.cobbzilla.wizard.server.config.OpenApiConfiguration.OPENAPI_DI
@Service @Slf4j
public class AnsiblePrepService {

private static final int MIN_OPEN_API_MEMORY = 4096;
public static final int OPEN_API_MIN_MEMORY = 4096;
public static final int SAGE_MIN_MEMORY = 256;
public static final int NODE_MIN_MEMORY = 200; // todo: can probably go lower, need to test

@Autowired private DatabaseFilterService dbFilter;
@Autowired private BubbleConfiguration configuration;
@@ -172,16 +174,16 @@ public class AnsiblePrepService {
private int jvmMaxRam(ComputeNodeSize nodeSize, AnsibleInstallType installType) {
final int memoryMB = nodeSize.getMemoryMB();
if (installType == AnsibleInstallType.sage) {
// at least 256MB, up to 60% of system memory
return Math.max(256, (int) (((double) memoryMB) * 0.6d));
// at least a minimum of SAGE_MIN_MEMORY, up to 60% of system memory
return Math.max(SAGE_MIN_MEMORY, (int) (((double) memoryMB) * 0.6d));
}
if (memoryMB >= 4096) return (int) (((double) memoryMB) * 0.6d);
if (memoryMB >= 2048) return (int) (((double) memoryMB) * 0.5d);
if (memoryMB >= 1024) return (int) (((double) memoryMB) * 0.24d);

// no nodes are this small, API probably would not start, not enough memory
// set floor at 200MB, might be able to go lower.
return Math.max(200, (int) (((double) memoryMB) * 0.19d));
// API will probably not start, system will likely run out of memory
// Why are you trying to run Bubble on less than 1GB RAM?
return Math.max(NODE_MIN_MEMORY, (int) (((double) memoryMB) * 0.22d));
}

private boolean shouldEnableOpenApi(AnsibleInstallType installType, ComputeNodeSize nodeSize) {
@@ -189,7 +191,7 @@ public class AnsiblePrepService {
// - it must already be enabled on the current bubble
// - the bubble being launched must be a sage or have 4GB+ memory
return configuration.hasOpenApi() &&
(installType == AnsibleInstallType.sage || nodeSize.getMemoryMB() >= MIN_OPEN_API_MEMORY);
(installType == AnsibleInstallType.sage || nodeSize.getMemoryMB() >= OPEN_API_MIN_MEMORY);
}

}

+ 2
- 2
bubble-server/src/main/java/bubble/service/cloud/StandardNetworkService.java 파일 보기

@@ -454,7 +454,7 @@ public class StandardNetworkService implements NetworkService {
}

if (progressMeter != null) {
if (!progressMeter.success() && configuration.paymentsEnabled()) {
if (!progressMeter.success() && configuration.getPaymentsEnabled()) {
final AccountPlan accountPlan = accountPlanDAO.findByNetwork(nn.getNetwork());
if (accountPlan != null) {
final BubblePlan plan = planDAO.findByUuid(accountPlan.getPlan());
@@ -691,7 +691,7 @@ public class StandardNetworkService implements NetworkService {
public NewNodeNotification startNetwork(BubbleNetwork network, NetLocation netLocation) {

final String accountUuid = network.getAccount();
if (configuration.paymentsEnabled()) {
if (configuration.getPaymentsEnabled()) {
AccountPlan accountPlan = accountPlanDAO.findByAccountAndNetwork(accountUuid, network.getUuid());
if (accountPlan == null) throw invalidEx("err.accountPlan.notFound");
final long start = now();


+ 1
- 1
bubble-server/src/main/java/bubble/service/dbfilter/FilteredEntityIterator.java 파일 보기

@@ -62,7 +62,7 @@ public class FilteredEntityIterator extends EntityIterator {
BubbleNode node,
List<BubblePlanApp> planApps,
AtomicReference<Exception> error) {
super(error, configuration.paymentsEnabled());
super(error, configuration.getPaymentsEnabled());
this.configuration = configuration;
this.account = account;
this.network = network;


+ 1
- 1
bubble-server/src/main/java/bubble/service/dbfilter/FullEntityIterator.java 파일 보기

@@ -31,7 +31,7 @@ public class FullEntityIterator extends EntityIterator {
BubbleNetwork network,
LaunchType launchType,
AtomicReference<Exception> error) {
super(error, configuration.paymentsEnabled());
super(error, configuration.getPaymentsEnabled());
this.configuration = configuration;
this.network = network;
this.account = account;


+ 2
- 0
bubble-server/src/main/java/bubble/service_dbfilter/DbFilterSelfNodeService.java 파일 보기

@@ -20,6 +20,8 @@ public class DbFilterSelfNodeService implements SelfNodeService {

@Override public boolean initThisNode(BubbleNode thisNode) { return notSupported("initThisNode"); }

@Override public BubbleNode getSageNode() { return notSupported("getSageNode"); }

@Override public BubbleNode getThisNode() { return notSupported("getThisNode"); }

@Override public BubbleNetwork getThisNetwork() { return notSupported("getThisNetwork"); }


+ 1
- 1
bubble-web

@@ -1 +1 @@
Subproject commit 638ded1a9f87f9a35ec997805332517a7bca85c2
Subproject commit e02f50bf55ae38cb81846adab9904d59db6f0255

+ 1
- 1
utils/cobbzilla-utils

@@ -1 +1 @@
Subproject commit 72e0962ac1defa342e8bad34044b56ee36817f52
Subproject commit 8e75e7087316977e9ee3a5201ba328e62f184ffa

불러오는 중...
취소
저장